Skip to main content

takumi_css/style/properties/
clip_path.rs

1use std::fmt;
2
3use crate::style::{ToCss, properties::write_css_string, unexpected_token};
4use cssparser::{Parser, Token, match_ignore_ascii_case};
5
6use crate::style::{
7  CssDescriptorKind, CssSyntaxKind, CssToken, FromCss, Length, MakeComputed, ParseResult, Sides,
8  SizingContext, SpacePair,
9};
10
11/// Represents the fill rule used for determining the interior of shapes.
12///
13/// Corresponds to the SVG fill-rule attribute and is used in polygon(), path(), and shape() functions.
14#[derive(Debug, Clone, Copy, PartialEq, Default)]
15pub enum FillRule {
16  /// The default rule - counts the number of times a ray from the point crosses the shape's edges
17  #[default]
18  NonZero,
19  /// Counts the total number of crossings - if even, the point is outside
20  EvenOdd,
21}
22
23/// Represents radius values for circle() and ellipse() functions.
24#[derive(Debug, Clone, Copy, PartialEq, Default)]
25pub enum ShapeRadius {
26  /// Uses the length from the center to the closest side of the reference box
27  #[default]
28  ClosestSide,
29  /// Uses the length from the center to the farthest side of the reference box
30  FarthestSide,
31  /// A specific length value
32  Length(Length),
33}
34
35impl MakeComputed for ShapeRadius {
36  fn make_computed(&mut self, sizing: &SizingContext) {
37    if let ShapeRadius::Length(length) = self {
38      length.make_computed(sizing);
39    }
40  }
41}
42
43/// Represents a position for circle() and ellipse() functions.
44#[derive(Debug, Clone, Copy, PartialEq)]
45#[non_exhaustive]
46pub struct ShapePosition(pub SpacePair<Length>);
47
48impl MakeComputed for ShapePosition {
49  fn make_computed(&mut self, sizing: &SizingContext) {
50    self.0.make_computed(sizing);
51  }
52}
53
54impl Default for ShapePosition {
55  fn default() -> Self {
56    Self(SpacePair::from_single(Length::Percentage(50.0)))
57  }
58}
59
60/// Represents an inset() rectangle shape.
61///
62/// The inset() function creates an inset rectangle, with its size defined by the offset distance
63/// of each of the four sides of its container and, optionally, rounded corners.
64#[derive(Debug, Clone, PartialEq)]
65#[non_exhaustive]
66pub struct InsetShape {
67  /// Sides of the inset.
68  pub inset: Sides<Length>,
69  /// Optional border radius for rounded corners
70  pub border_radius: Option<Sides<Length>>,
71}
72
73impl MakeComputed for InsetShape {
74  fn make_computed(&mut self, sizing: &SizingContext) {
75    self.inset.make_computed(sizing);
76    self.border_radius.make_computed(sizing);
77  }
78}
79
80/// Represents an ellipse() shape.
81#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub struct EllipseShape {
84  /// The horizontal radius
85  pub radius_x: ShapeRadius,
86  /// The vertical radius
87  pub radius_y: ShapeRadius,
88  /// The center position of the ellipse
89  pub position: ShapePosition,
90}
91
92impl MakeComputed for EllipseShape {
93  fn make_computed(&mut self, sizing: &SizingContext) {
94    self.radius_x.make_computed(sizing);
95    self.radius_y.make_computed(sizing);
96    self.position.make_computed(sizing);
97  }
98}
99
100/// Represents a single coordinate pair in a polygon.
101pub type PolygonCoordinate = SpacePair<Length>;
102
103/// Represents a polygon() shape.
104#[derive(Debug, Clone, PartialEq)]
105#[non_exhaustive]
106pub struct PolygonShape {
107  /// The fill rule to use
108  pub fill_rule: Option<FillRule>,
109  /// List of coordinate pairs defining the polygon vertices
110  pub coordinates: Box<[PolygonCoordinate]>,
111}
112
113impl MakeComputed for PolygonShape {
114  fn make_computed(&mut self, sizing: &SizingContext) {
115    self.coordinates.make_computed(sizing);
116  }
117}
118
119/// Represents a path() shape using an SVG path string.
120#[derive(Debug, Clone, PartialEq)]
121#[non_exhaustive]
122pub struct PathShape {
123  /// The fill rule to use
124  pub fill_rule: Option<FillRule>,
125  /// SVG path data string
126  pub path: Box<str>,
127}
128
129/// Represents a basic shape function for clip-path.
130#[derive(Debug, Clone, PartialEq)]
131pub enum BasicShape {
132  /// inset() function
133  Inset(Box<InsetShape>),
134  /// ellipse() function
135  Ellipse(Box<EllipseShape>),
136  /// polygon() function
137  Polygon(PolygonShape),
138  /// path() function
139  Path(PathShape),
140}
141
142impl MakeComputed for BasicShape {
143  fn make_computed(&mut self, sizing: &SizingContext) {
144    match self {
145      BasicShape::Inset(shape) => shape.make_computed(sizing),
146      BasicShape::Ellipse(shape) => shape.make_computed(sizing),
147      BasicShape::Polygon(shape) => shape.make_computed(sizing),
148      BasicShape::Path(_) => {}
149    }
150  }
151}
152
153impl BasicShape {
154  pub fn fill_rule(&self) -> Option<FillRule> {
155    match self {
156      BasicShape::Polygon(shape) => shape.fill_rule,
157      BasicShape::Path(shape) => shape.fill_rule,
158      _ => None,
159    }
160  }
161}
162
163crate::style::properties::declare_enum_from_css_impl!(
164  FillRule,
165  "nonzero" => FillRule::NonZero,
166  "evenodd" => FillRule::EvenOdd,
167);
168
169impl<'i> FromCss<'i> for ShapeRadius {
170  fn from_css(parser: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
171    let location = parser.current_source_location();
172
173    // Try parsing as length first
174    if let Ok(length) = parser.try_parse(Length::from_css) {
175      return Ok(ShapeRadius::Length(length));
176    }
177
178    // Try parsing keywords
179    let ident = parser.expect_ident()?;
180    match_ignore_ascii_case! { &ident,
181      "closest-side" => Ok(ShapeRadius::ClosestSide),
182      "farthest-side" => Ok(ShapeRadius::FarthestSide),
183      _ => Err(unexpected_token!(location, &Token::Ident(ident.clone()))),
184    }
185  }
186
187  const VALID_TOKENS: &'static [CssToken] = &[
188    CssToken::Keyword("closest-side"),
189    CssToken::Keyword("farthest-side"),
190    CssToken::Syntax(CssSyntaxKind::Length),
191  ];
192}
193
194impl<'i> FromCss<'i> for ShapePosition {
195  fn from_css(parser: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
196    let first = Length::from_css(parser)?;
197
198    // If there's a second value, parse it; otherwise default to 50%
199    let second = parser
200      .try_parse(Length::from_css)
201      .unwrap_or(Length::Percentage(50.0));
202
203    Ok(ShapePosition(SpacePair::from_pair(first, second)))
204  }
205
206  const VALID_TOKENS: &'static [CssToken] = Length::<true>::VALID_TOKENS;
207}
208
209impl<'i> FromCss<'i> for BasicShape {
210  fn from_css(parser: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
211    let location = parser.current_source_location();
212    let token = parser.next()?;
213
214    match token {
215      Token::Function(function) => {
216        match_ignore_ascii_case! { &function,
217          "inset" => parser.parse_nested_block(|input| {
218            let inset = Sides::from_css(input)?;
219
220            // Parse border radius with "round" keyword
221            let border_radius = if input.try_parse(|input| input.expect_ident_matching("round")).is_ok() {
222              Some(Sides::from_css(input)?)
223            } else {
224              None
225            };
226
227            Ok(BasicShape::Inset(Box::new(InsetShape {
228              inset,
229              border_radius,
230            })))
231          }),
232          "circle" => parser.parse_nested_block(|input| {
233            let radius = input.try_parse(ShapeRadius::from_css).unwrap_or_default();
234
235            let position = if input.try_parse(|input| input.expect_ident_matching("at")).is_ok() {
236              ShapePosition::from_css(input)?
237            } else {
238              ShapePosition::default()
239            };
240
241            Ok(BasicShape::Ellipse(Box::new(EllipseShape { radius_x: radius, radius_y: radius, position })))
242          }),
243          "ellipse" => parser.parse_nested_block(|input| {
244            let radius_x = ShapeRadius::from_css(input)?;
245            let radius_y = input.try_parse(ShapeRadius::from_css).unwrap_or_default();
246
247            let position = if input.try_parse(|input| input.expect_ident_matching("at")).is_ok() {
248              ShapePosition::from_css(input)?
249            } else {
250              ShapePosition::default()
251            };
252
253            Ok(BasicShape::Ellipse(Box::new(EllipseShape { radius_x, radius_y, position })))
254          }),
255          "polygon" => parser.parse_nested_block(|input| {
256            let fill_rule = input.try_parse(FillRule::from_css).ok();
257            if fill_rule.is_some() {
258              input.expect_comma()?;
259            }
260
261            Ok(BasicShape::Polygon(PolygonShape {
262              fill_rule,
263              coordinates: input
264                .parse_comma_separated(PolygonCoordinate::from_css)?
265                .into_boxed_slice(),
266            }))
267          }),
268          "path" => parser.parse_nested_block(|input| {
269            let fill_rule = input.try_parse(FillRule::from_css).ok();
270            if fill_rule.is_some() {
271              input.expect_comma()?;
272            }
273
274            let path = input.expect_string()?.as_ref().into();
275
276            Ok(BasicShape::Path(PathShape {
277              fill_rule,
278              path,
279            }))
280          }),
281          _ => Err(unexpected_token!(location, token)),
282        }
283      }
284      _ => Err(unexpected_token!(location, token)),
285    }
286  }
287
288  const VALID_TOKENS: &'static [CssToken] = &[
289    CssToken::Descriptor(CssDescriptorKind::InsetFn),
290    CssToken::Descriptor(CssDescriptorKind::CircleFn),
291    CssToken::Descriptor(CssDescriptorKind::EllipseFn),
292    CssToken::Descriptor(CssDescriptorKind::PolygonFn),
293    CssToken::Descriptor(CssDescriptorKind::PathFn),
294  ];
295}
296
297impl ToCss for ShapeRadius {
298  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
299    match self {
300      Self::ClosestSide => dest.write_str("closest-side"),
301      Self::FarthestSide => dest.write_str("farthest-side"),
302      Self::Length(l) => l.to_css(dest),
303    }
304  }
305}
306
307impl ToCss for ShapePosition {
308  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
309    self.0.to_css(dest)
310  }
311}
312
313impl ToCss for BasicShape {
314  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
315    match self {
316      Self::Inset(shape) => {
317        dest.write_str("inset(")?;
318        shape.inset.to_css(dest)?;
319        if let Some(radius) = &shape.border_radius {
320          dest.write_str(" round ")?;
321          radius.to_css(dest)?;
322        }
323        dest.write_char(')')
324      }
325      Self::Ellipse(shape) => {
326        if shape.radius_x == shape.radius_y {
327          dest.write_str("circle(")?;
328          let mut has_radius = false;
329          if shape.radius_x != ShapeRadius::ClosestSide {
330            shape.radius_x.to_css(dest)?;
331            has_radius = true;
332          }
333          if shape.position != ShapePosition::default() {
334            if has_radius {
335              dest.write_char(' ')?;
336            }
337            dest.write_str("at ")?;
338            shape.position.to_css(dest)?;
339          }
340          dest.write_char(')')
341        } else {
342          dest.write_str("ellipse(")?;
343          shape.radius_x.to_css(dest)?;
344          dest.write_char(' ')?;
345          shape.radius_y.to_css(dest)?;
346          if shape.position != ShapePosition::default() {
347            dest.write_str(" at ")?;
348            shape.position.to_css(dest)?;
349          }
350          dest.write_char(')')
351        }
352      }
353      Self::Polygon(shape) => {
354        dest.write_str("polygon(")?;
355        if let Some(rule) = shape.fill_rule {
356          rule.to_css(dest)?;
357          dest.write_str(", ")?;
358        }
359        let mut first = true;
360        for coord in shape.coordinates.iter() {
361          if !first {
362            dest.write_str(", ")?;
363          }
364          coord.to_css(dest)?;
365          first = false;
366        }
367        dest.write_char(')')
368      }
369      Self::Path(shape) => {
370        dest.write_str("path(")?;
371        if let Some(rule) = shape.fill_rule {
372          rule.to_css(dest)?;
373          dest.write_str(", ")?;
374        }
375        write_css_string(dest, &shape.path)?;
376        dest.write_char(')')
377      }
378    }
379  }
380}
381
382#[cfg(test)]
383mod tests {
384  use std::assert_matches;
385
386  use super::*;
387  use Length::*;
388
389  #[test]
390  fn test_parse_inset_simple() {
391    assert_eq!(
392      BasicShape::from_str("inset(10px)"),
393      Ok(BasicShape::Inset(Box::new(InsetShape {
394        inset: Sides([Px(10.0); 4]),
395        border_radius: None,
396      })))
397    );
398  }
399
400  #[test]
401  fn test_parse_inset_four_values() {
402    assert_eq!(
403      BasicShape::from_str("inset(10px 20px 30px 40px)"),
404      Ok(BasicShape::Inset(Box::new(InsetShape {
405        inset: Sides([Px(10.0), Px(20.0), Px(30.0), Px(40.0)]),
406        border_radius: None,
407      })))
408    );
409  }
410
411  #[test]
412  fn test_parse_inset_with_border_radius() {
413    assert_eq!(
414      BasicShape::from_str("inset(10px round 5px)"),
415      Ok(BasicShape::Inset(Box::new(InsetShape {
416        inset: Sides::from(Px(10.0)),
417        border_radius: Some(Sides::from(Px(5.0))),
418      })))
419    );
420  }
421
422  #[test]
423  fn test_parse_inset_with_complex_border_radius() {
424    assert_eq!(
425      BasicShape::from_str("inset(10px 20px 30px 40px round 5px 10px 15px 20px)"),
426      Ok(BasicShape::Inset(Box::new(InsetShape {
427        inset: Sides([Px(10.0), Px(20.0), Px(30.0), Px(40.0)]),
428        border_radius: Some(Sides([Px(5.0), Px(10.0), Px(15.0), Px(20.0)])),
429      })))
430    );
431  }
432
433  #[test]
434  fn test_parse_circle_simple() {
435    assert_eq!(
436      BasicShape::from_str("circle(50px)"),
437      Ok(BasicShape::Ellipse(Box::new(EllipseShape {
438        radius_x: ShapeRadius::Length(Px(50.0)),
439        radius_y: ShapeRadius::Length(Px(50.0)),
440        position: ShapePosition::default(),
441      })))
442    );
443  }
444
445  #[test]
446  fn test_parse_circle_with_position() {
447    assert_eq!(
448      BasicShape::from_str("circle(50px at 25% 75%)"),
449      Ok(BasicShape::Ellipse(Box::new(EllipseShape {
450        radius_x: ShapeRadius::Length(Px(50.0)),
451        radius_y: ShapeRadius::Length(Px(50.0)),
452        position: ShapePosition(SpacePair {
453          x: Length::Percentage(25.0),
454          y: Length::Percentage(75.0),
455        }),
456      })))
457    );
458  }
459
460  #[test]
461  fn test_parse_circle_default_radius() {
462    assert_eq!(
463      BasicShape::from_str("circle(at 25% 75%)"),
464      Ok(BasicShape::Ellipse(Box::new(EllipseShape {
465        radius_x: ShapeRadius::ClosestSide,
466        radius_y: ShapeRadius::ClosestSide,
467        position: ShapePosition(SpacePair {
468          x: Length::Percentage(25.0),
469          y: Length::Percentage(75.0),
470        }),
471      })))
472    );
473  }
474
475  #[test]
476  fn test_parse_ellipse_simple() {
477    assert_eq!(
478      BasicShape::from_str("ellipse(50px 30px)"),
479      Ok(BasicShape::Ellipse(Box::new(EllipseShape {
480        radius_x: ShapeRadius::Length(Px(50.0)),
481        radius_y: ShapeRadius::Length(Px(30.0)),
482        position: ShapePosition::default(),
483      })))
484    );
485  }
486
487  #[test]
488  fn test_parse_ellipse_with_position() {
489    assert_eq!(
490      BasicShape::from_str("ellipse(50px 30px at 25% 75%)"),
491      Ok(BasicShape::Ellipse(Box::new(EllipseShape {
492        radius_x: ShapeRadius::Length(Px(50.0)),
493        radius_y: ShapeRadius::Length(Px(30.0)),
494        position: ShapePosition(SpacePair {
495          x: Length::Percentage(25.0),
496          y: Length::Percentage(75.0),
497        }),
498      })))
499    );
500  }
501
502  #[test]
503  fn test_parse_polygon_triangle() {
504    assert_matches!(
505      BasicShape::from_str("polygon(50% 0%, 0% 100%, 100% 100%)"),
506      Ok(BasicShape::Polygon(PolygonShape {
507        fill_rule: None,
508        coordinates: coords,
509      })) if coords.len() == 3 &&
510            coords[0] == SpacePair { x: Length::Percentage(50.0), y: Length::Percentage(0.0) } &&
511            coords[1] == SpacePair { x: Length::Percentage(0.0), y: Length::Percentage(100.0) } &&
512            coords[2] == SpacePair { x: Length::Percentage(100.0), y: Length::Percentage(100.0) }
513    );
514  }
515
516  #[test]
517  fn test_parse_polygon_with_fill_rule() {
518    assert_matches!(
519      BasicShape::from_str("polygon(evenodd, 50% 0%, 0% 100%, 100% 100%)"),
520      Ok(BasicShape::Polygon(PolygonShape {
521        fill_rule: Some(FillRule::EvenOdd),
522        coordinates: coords,
523      })) if coords.len() == 3
524    );
525  }
526
527  #[test]
528  fn test_parse_path() {
529    assert_eq!(
530      BasicShape::from_str("path('M 10 10 L 90 90')"),
531      Ok(BasicShape::Path(PathShape {
532        fill_rule: None,
533        path: "M 10 10 L 90 90".into(),
534      }))
535    );
536  }
537
538  #[test]
539  fn test_parse_path_with_fill_rule() {
540    assert_eq!(
541      BasicShape::from_str("path(evenodd, 'M 10 10 L 90 90')"),
542      Ok(BasicShape::Path(PathShape {
543        fill_rule: Some(FillRule::EvenOdd),
544        path: "M 10 10 L 90 90".into(),
545      }))
546    );
547  }
548
549  #[test]
550  fn test_parse_circle_percentage_radius() {
551    assert_eq!(
552      BasicShape::from_str("circle(50%)"),
553      Ok(BasicShape::Ellipse(Box::new(EllipseShape {
554        radius_x: ShapeRadius::Length(Length::Percentage(50.0)),
555        radius_y: ShapeRadius::Length(Length::Percentage(50.0)),
556        position: ShapePosition::default(),
557      })))
558    );
559  }
560
561  #[test]
562  fn test_parse_circle_closest_side() {
563    assert_eq!(
564      BasicShape::from_str("circle(closest-side)"),
565      Ok(BasicShape::Ellipse(Box::new(EllipseShape {
566        radius_x: ShapeRadius::ClosestSide,
567        radius_y: ShapeRadius::ClosestSide,
568        position: ShapePosition::default(),
569      })))
570    );
571  }
572
573  #[test]
574  fn test_parse_circle_farthest_side() {
575    assert_eq!(
576      BasicShape::from_str("circle(farthest-side)"),
577      Ok(BasicShape::Ellipse(Box::new(EllipseShape {
578        radius_x: ShapeRadius::FarthestSide,
579        radius_y: ShapeRadius::FarthestSide,
580        position: ShapePosition::default(),
581      })))
582    );
583  }
584}