takumi_css/style/properties/
content.rs1use cssparser::{Parser, Token, match_ignore_ascii_case};
2use std::{fmt, sync::Arc};
3
4use crate::style::{
5 Animatable, BackgroundImage, CssSyntaxKind, CssToken, FromCss, MakeComputed, ParseResult, ToCss,
6 properties::write_css_string, tw::TailwindPropertyParser, unexpected_token,
7};
8
9#[derive(Debug, Clone, Default, PartialEq)]
11pub enum ContentValue {
12 #[default]
14 Normal,
15 None,
17 Items(Box<[ContentItem]>),
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum ContentItem {
24 Text(Arc<str>),
26 Image(Box<BackgroundImage>),
28 Attr(AttrRef),
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct AttrRef {
36 pub name: Arc<str>,
38 pub fallback: Arc<str>,
40}
41
42impl MakeComputed for ContentValue {
43 fn make_computed(&mut self, sizing: &crate::style::SizingContext) {
44 if let ContentValue::Items(items) = self {
45 for item in items.iter_mut() {
46 if let ContentItem::Image(image) = item {
47 image.as_mut().make_computed(sizing);
48 }
49 }
50 }
51 }
52}
53
54impl Animatable for ContentValue {}
55
56impl TailwindPropertyParser for ContentValue {
57 fn parse_tw(token: &str) -> Option<Self> {
58 match_ignore_ascii_case! {token,
59 "none" => Some(ContentValue::None),
60 "normal" => Some(ContentValue::Normal),
61 _ => None,
62 }
63 }
64}
65
66impl<'i> FromCss<'i> for ContentValue {
67 fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
68 if input
69 .try_parse(|input| input.expect_ident_matching("none"))
70 .is_ok()
71 {
72 return Ok(ContentValue::None);
73 }
74
75 if input
76 .try_parse(|input| input.expect_ident_matching("normal"))
77 .is_ok()
78 {
79 return Ok(ContentValue::Normal);
80 }
81
82 let mut items = Vec::new();
83 while !input.is_exhausted() {
84 items.push(ContentItem::from_css(input)?);
85 }
86
87 if items.is_empty() {
88 let location = input.current_source_location();
89 return Err(unexpected_token!(Self, location, &Token::WhiteSpace("")));
90 }
91
92 Ok(ContentValue::Items(items.into_boxed_slice()))
93 }
94
95 const VALID_TOKENS: &'static [CssToken] = &[
96 CssToken::Keyword("none"),
97 CssToken::Keyword("normal"),
98 CssToken::Syntax(CssSyntaxKind::String),
99 CssToken::Syntax(CssSyntaxKind::Image),
100 ];
101}
102
103impl<'i> FromCss<'i> for ContentItem {
104 fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
105 let start = input.state();
106 if let Ok(image) = input.try_parse(BackgroundImage::from_css) {
107 if !matches!(image, BackgroundImage::None) {
108 return Ok(ContentItem::Image(Box::new(image)));
109 }
110 input.reset(&start);
113 }
114
115 let location = input.current_source_location();
116 let token = input.next()?.clone();
117 match token {
118 Token::QuotedString(value) => Ok(ContentItem::Text(value.as_ref().into())),
119 Token::Function(ref name) if name.eq_ignore_ascii_case("attr") => input
120 .parse_nested_block(AttrRef::from_css)
121 .map(ContentItem::Attr),
122 other => Err(unexpected_token!(Self, location, &other)),
123 }
124 }
125
126 const VALID_TOKENS: &'static [CssToken] = &[
127 CssToken::Syntax(CssSyntaxKind::String),
128 CssToken::Syntax(CssSyntaxKind::Image),
129 ];
130}
131
132impl<'i> FromCss<'i> for AttrRef {
133 fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
134 let name: Arc<str> = input.expect_ident()?.as_ref().into();
135 let fallback: Arc<str> = if input.try_parse(Parser::expect_comma).is_ok() {
136 input.expect_string()?.as_ref().into()
137 } else {
138 "".into()
139 };
140 Ok(Self { name, fallback })
141 }
142
143 const VALID_TOKENS: &'static [CssToken] = &[CssToken::Syntax(CssSyntaxKind::Ident)];
144}
145
146impl ToCss for ContentValue {
147 fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
148 match self {
149 ContentValue::Normal => dest.write_str("normal"),
150 ContentValue::None => dest.write_str("none"),
151 ContentValue::Items(items) => {
152 for (i, item) in items.iter().enumerate() {
153 if i > 0 {
154 dest.write_char(' ')?;
155 }
156 item.to_css(dest)?;
157 }
158 Ok(())
159 }
160 }
161 }
162}
163
164impl ToCss for ContentItem {
165 fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
166 match self {
167 ContentItem::Text(value) => write_css_string(dest, value),
168 ContentItem::Image(image) => image.to_css(dest),
169 ContentItem::Attr(attr) => attr.to_css(dest),
170 }
171 }
172}
173
174impl ToCss for AttrRef {
175 fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
176 dest.write_str("attr(")?;
177 dest.write_str(&self.name)?;
178 if !self.fallback.is_empty() {
179 dest.write_str(", ")?;
180 write_css_string(dest, &self.fallback)?;
181 }
182 dest.write_char(')')
183 }
184}
185
186#[cfg(test)]
187#[allow(clippy::panic, clippy::expect_used)]
188mod tests {
189 use std::assert_matches;
190
191 use super::*;
192
193 fn parse(input: &str) -> ContentValue {
194 ContentValue::from_str(input).expect("parse")
195 }
196
197 #[test]
198 fn parses_none_and_normal() {
199 assert_eq!(parse("none"), ContentValue::None);
200 assert_eq!(parse("normal"), ContentValue::Normal);
201 }
202
203 #[test]
204 fn parses_single_string() {
205 let ContentValue::Items(items) = parse("\"hello\"") else {
206 panic!("expected items");
207 };
208 assert_eq!(items.len(), 1);
209 assert_eq!(items[0], ContentItem::Text("hello".into()));
210 }
211
212 #[test]
213 fn parses_multiple_strings_as_list() {
214 let ContentValue::Items(items) = parse("\"a\" \"b\"") else {
215 panic!("expected items");
216 };
217 assert_eq!(items.len(), 2);
218 }
219
220 #[test]
221 fn parses_attr_without_fallback() {
222 let ContentValue::Items(items) = parse("attr(label)") else {
223 panic!("expected items");
224 };
225 let ContentItem::Attr(attr) = &items[0] else {
226 panic!("expected attr");
227 };
228 assert_eq!(&*attr.name, "label");
229 assert_eq!(&*attr.fallback, "");
230 }
231
232 #[test]
233 fn parses_attr_with_fallback() {
234 let ContentValue::Items(items) = parse("attr(label, \"unknown\")") else {
235 panic!("expected items");
236 };
237 let ContentItem::Attr(attr) = &items[0] else {
238 panic!("expected attr");
239 };
240 assert_eq!(&*attr.fallback, "unknown");
241 }
242
243 #[test]
244 fn parses_url_image() {
245 let ContentValue::Items(items) = parse("url(\"icon.png\")") else {
246 panic!("expected items");
247 };
248 assert_matches!(
249 &items[0],
250 ContentItem::Image(image) if matches!(**image, BackgroundImage::Url(_))
251 );
252 }
253
254 #[test]
255 fn parses_mixed_list() {
256 let ContentValue::Items(items) = parse("\"Prefix: \" attr(name) url(\"icon.png\")") else {
257 panic!("expected items");
258 };
259 assert_eq!(items.len(), 3);
260 }
261
262 #[test]
263 fn unsupported_function_is_rejected() {
264 assert!(ContentValue::from_str("counter(foo)").is_err());
265 assert!(ContentValue::from_str("\"prefix\" counter(foo)").is_err());
266 }
267
268 #[test]
269 fn unsupported_keyword_is_rejected() {
270 assert!(ContentValue::from_str("open-quote").is_err());
271 assert!(ContentValue::from_str("\"x\" close-quote").is_err());
272 }
273}