Skip to main content

velox_style/
visual_effects.rs

1//! Visual effects and decorations for CSS
2//!
3//! Handles border-radius, opacity, shadows, and other visual effects
4
5use velox_dom::style::Length;
6
7/// Border radius for corners
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct BorderRadius {
10    pub top_left: Length,
11    pub top_right: Length,
12    pub bottom_right: Length,
13    pub bottom_left: Length,
14}
15
16impl BorderRadius {
17    pub fn parse(value: &str) -> Option<Self> {
18        let parts: Vec<&str> = value.split_whitespace().collect();
19
20        match parts.len() {
21            1 => {
22                let v = Length::parse(parts[0])?;
23                Some(Self {
24                    top_left: v,
25                    top_right: v,
26                    bottom_right: v,
27                    bottom_left: v,
28                })
29            }
30            2 => {
31                let h = Length::parse(parts[0])?;
32                let v = Length::parse(parts[1])?;
33                Some(Self {
34                    top_left: h,
35                    top_right: v,
36                    bottom_right: h,
37                    bottom_left: v,
38                })
39            }
40            3 => {
41                let tl = Length::parse(parts[0])?;
42                let tr = Length::parse(parts[1])?;
43                let br = Length::parse(parts[2])?;
44                Some(Self {
45                    top_left: tl,
46                    top_right: tr,
47                    bottom_right: br,
48                    bottom_left: tr,
49                })
50            }
51            4 => {
52                let tl = Length::parse(parts[0])?;
53                let tr = Length::parse(parts[1])?;
54                let br = Length::parse(parts[2])?;
55                let bl = Length::parse(parts[3])?;
56                Some(Self {
57                    top_left: tl,
58                    top_right: tr,
59                    bottom_right: br,
60                    bottom_left: bl,
61                })
62            }
63            _ => None,
64        }
65    }
66
67    pub fn all(radius: Length) -> Self {
68        Self {
69            top_left: radius,
70            top_right: radius,
71            bottom_right: radius,
72            bottom_left: radius,
73        }
74    }
75}
76
77impl Default for BorderRadius {
78    fn default() -> Self {
79        Self::all(Length::Zero)
80    }
81}
82
83/// Box shadow effect
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct BoxShadow {
86    /// Horizontal offset
87    pub offset_x: f32,
88    /// Vertical offset
89    pub offset_y: f32,
90    /// Blur radius
91    pub blur: f32,
92    /// Spread radius (optional)
93    pub spread: f32,
94    /// Shadow color (rgba)
95    pub color: (u8, u8, u8, u8),
96    /// Inset shadow?
97    pub inset: bool,
98}
99
100impl BoxShadow {
101    pub fn new(offset_x: f32, offset_y: f32, blur: f32, color: (u8, u8, u8, u8)) -> Self {
102        Self {
103            offset_x,
104            offset_y,
105            blur,
106            spread: 0.0,
107            color,
108            inset: false,
109        }
110    }
111
112    pub fn with_spread(mut self, spread: f32) -> Self {
113        self.spread = spread;
114        self
115    }
116
117    pub fn inset(mut self) -> Self {
118        self.inset = true;
119        self
120    }
121
122    /// Parse box-shadow: offset-x offset-y blur spread color
123    /// Example: "2px 2px 5px 1px rgba(0,0,0,0.3)"
124    pub fn parse(value: &str) -> Option<Self> {
125        let value = value.trim();
126        let inset = value.starts_with("inset");
127        // `strip_prefix` cannot fail after the `starts_with` check above, but a
128        // `map(...).unwrap_or(value)` keeps a malformed value parseable instead
129        // of panicking the styling pass: the fallback simply parses without the
130        // inset prefix stripped.
131        let value = if inset {
132            value.strip_prefix("inset").map(str::trim).unwrap_or(value)
133        } else {
134            value
135        };
136
137        let mut offset_x = 0.0;
138        let mut offset_y = 0.0;
139        let mut blur = 0.0;
140        let mut spread = 0.0;
141        let mut color = (0u8, 0u8, 0u8, 128u8);
142
143        // Collect all parts as owned Strings, handling multi-token color values like "rgba(0, 0, 0, 0.3)"
144        let mut parts: Vec<String> = Vec::new();
145        let mut current_part = String::new();
146        let mut paren_depth = 0;
147
148        for token in value.split_whitespace() {
149            let open_parens = token.matches('(').count();
150            let close_parens = token.matches(')').count();
151
152            if paren_depth == 0 && open_parens == 0 && close_parens == 0 {
153                // Simple token - add directly
154                parts.push(token.to_string());
155            } else {
156                // Part of a function-like value (e.g., rgba, rgb)
157                if paren_depth == 0 {
158                    current_part.clear();
159                }
160                if !current_part.is_empty() {
161                    current_part.push(' ');
162                }
163                current_part.push_str(token);
164                paren_depth += open_parens as i32 - close_parens as i32;
165
166                if paren_depth == 0 && !current_part.is_empty() {
167                    parts.push(current_part.clone());
168                }
169            }
170        }
171
172        // If no multi-token colors were found, fall back to simple split
173        if parts.is_empty() {
174            parts = value.split_whitespace().map(|s| s.to_string()).collect();
175        }
176
177        let mut part_idx = 0;
178
179        // Parse offsets and blur
180        if part_idx < parts.len() {
181            let p = &parts[part_idx];
182            offset_x = p
183                .strip_suffix("px")
184                .and_then(|s| s.parse().ok())
185                .unwrap_or(0.0);
186            part_idx += 1;
187        }
188        if part_idx < parts.len() {
189            let p = &parts[part_idx];
190            offset_y = p
191                .strip_suffix("px")
192                .and_then(|s| s.parse().ok())
193                .unwrap_or(0.0);
194            part_idx += 1;
195        }
196        if part_idx < parts.len() {
197            let p = &parts[part_idx];
198            blur = p
199                .strip_suffix("px")
200                .and_then(|s| s.parse().ok())
201                .unwrap_or(0.0);
202            part_idx += 1;
203        }
204        // Fourth token could be spread (px) or color
205        if part_idx < parts.len() {
206            let p = &parts[part_idx];
207            if p.ends_with("px") {
208                spread = p
209                    .strip_suffix("px")
210                    .and_then(|s| s.parse().ok())
211                    .unwrap_or(0.0);
212                part_idx += 1;
213            }
214        }
215        // Remaining token(s) should be color
216        if part_idx < parts.len() {
217            let color_str = parts[part_idx..].join(" ");
218            if let Some(c) = velox_dom::style::Color::parse(&color_str) {
219                color = (c.r, c.g, c.b, c.a);
220            }
221        }
222
223        Some(BoxShadow {
224            offset_x,
225            offset_y,
226            blur,
227            spread,
228            color,
229            inset,
230        })
231    }
232}
233
234impl Default for BoxShadow {
235    fn default() -> Self {
236        Self {
237            offset_x: 0.0,
238            offset_y: 0.0,
239            blur: 0.0,
240            spread: 0.0,
241            color: (0, 0, 0, 128),
242            inset: false,
243        }
244    }
245}
246
247/// Text shadow effect
248#[derive(Debug, Clone, Copy, PartialEq)]
249pub struct TextShadow {
250    /// Horizontal offset
251    pub offset_x: f32,
252    /// Vertical offset
253    pub offset_y: f32,
254    /// Blur radius
255    pub blur: f32,
256    /// Shadow color (rgba)
257    pub color: (u8, u8, u8, u8),
258}
259
260impl TextShadow {
261    pub fn new(offset_x: f32, offset_y: f32, blur: f32, color: (u8, u8, u8, u8)) -> Self {
262        Self {
263            offset_x,
264            offset_y,
265            blur,
266            color,
267        }
268    }
269
270    /// Parse text-shadow: offset-x offset-y blur color
271    pub fn parse(value: &str) -> Option<Self> {
272        let value = value.trim();
273        let mut offset_x = 0.0;
274        let mut offset_y = 0.0;
275        let mut blur = 0.0;
276        let mut color = (0u8, 0u8, 0u8, 128u8);
277
278        // Collect all parts as owned Strings, handling multi-token color values like "rgba(0, 0, 0, 0.3)"
279        let mut parts: Vec<String> = Vec::new();
280        let mut current_part = String::new();
281        let mut paren_depth = 0;
282
283        for token in value.split_whitespace() {
284            let open_parens = token.matches('(').count();
285            let close_parens = token.matches(')').count();
286
287            if paren_depth == 0 && open_parens == 0 && close_parens == 0 {
288                parts.push(token.to_string());
289            } else {
290                if paren_depth == 0 {
291                    current_part.clear();
292                }
293                if !current_part.is_empty() {
294                    current_part.push(' ');
295                }
296                current_part.push_str(token);
297                paren_depth += open_parens as i32 - close_parens as i32;
298
299                if paren_depth == 0 && !current_part.is_empty() {
300                    parts.push(current_part.clone());
301                }
302            }
303        }
304
305        if parts.is_empty() {
306            parts = value.split_whitespace().map(|s| s.to_string()).collect();
307        }
308
309        let mut part_idx = 0;
310
311        if part_idx < parts.len() {
312            let p = &parts[part_idx];
313            offset_x = p
314                .strip_suffix("px")
315                .and_then(|s| s.parse().ok())
316                .unwrap_or(0.0);
317            part_idx += 1;
318        }
319        if part_idx < parts.len() {
320            let p = &parts[part_idx];
321            offset_y = p
322                .strip_suffix("px")
323                .and_then(|s| s.parse().ok())
324                .unwrap_or(0.0);
325            part_idx += 1;
326        }
327        // Third token could be blur (px) or color
328        if part_idx < parts.len() {
329            let p = &parts[part_idx];
330            if p.ends_with("px") {
331                blur = p
332                    .strip_suffix("px")
333                    .and_then(|s| s.parse().ok())
334                    .unwrap_or(0.0);
335                part_idx += 1;
336            }
337        }
338        // Remaining token(s) should be color
339        if part_idx < parts.len() {
340            let color_str = parts[part_idx..].join(" ");
341            if let Some(c) = velox_dom::style::Color::parse(&color_str) {
342                color = (c.r, c.g, c.b, c.a);
343            }
344        }
345
346        Some(TextShadow {
347            offset_x,
348            offset_y,
349            blur,
350            color,
351        })
352    }
353}
354
355impl Default for TextShadow {
356    fn default() -> Self {
357        Self {
358            offset_x: 0.0,
359            offset_y: 0.0,
360            blur: 0.0,
361            color: (0, 0, 0, 128),
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_border_radius_single() {
372        let br = BorderRadius::parse("8px").unwrap();
373        assert_eq!(br.top_left, Length::Px(8.0));
374        assert_eq!(br.bottom_right, Length::Px(8.0));
375    }
376
377    #[test]
378    fn test_border_radius_four() {
379        let br = BorderRadius::parse("4px 8px 12px 16px").unwrap();
380        assert_eq!(br.top_left, Length::Px(4.0));
381        assert_eq!(br.top_right, Length::Px(8.0));
382        assert_eq!(br.bottom_right, Length::Px(12.0));
383        assert_eq!(br.bottom_left, Length::Px(16.0));
384    }
385
386    #[test]
387    fn test_box_shadow() {
388        let shadow = BoxShadow::new(2.0, 2.0, 5.0, (0, 0, 0, 200));
389        assert_eq!(shadow.offset_x, 2.0);
390        assert_eq!(shadow.blur, 5.0);
391        assert!(!shadow.inset);
392    }
393}