Skip to main content

ferroui_base/media/effects/
effect.rs

1use crate::animation::Animatable;
2use crate::media::effects::{IEffect, ImmutableBlurEffect, ImmutableDropShadowEffect};
3use crate::media::{Color, Colors};
4use crate::reactive::{Disposable, IDisposable};
5use crate::utilities::{FormatError, HandlerList};
6use crate::{ferro_class, ferro_impl_classes, FerroObject, FerroObjectImpl, FerroProperty, ObjectType, Upcast};
7use std::rc::Rc;
8
9/// Base class of the mutable effects.
10#[repr(C)]
11pub struct Effect {
12    base: Animatable,
13    invalidated: HandlerList<dyn Fn()>,
14}
15
16ferro_class!(Effect: Animatable);
17crate::ferro_class_info!(Effect { interfaces: [std::rc::Rc<dyn crate::media::effects::IEffect>] });
18ferro_impl_classes!(Effect: FerroObjectImpl);
19
20impl Effect {
21    /// Creates the class data; see [`FerroObject::construct`].
22    pub fn construct() -> Self {
23        Self { base: Animatable::construct(), invalidated: HandlerList::new() }
24    }
25
26    /// Marks a property on a class deriving from [`Effect`] as affecting the
27    /// effect's visual appearance: a change raises
28    /// [`invalidated`](Self::invalidated).
29    pub fn affects_render<T: ObjectType + Upcast<Effect>>(properties: &[&'static FerroProperty]) {
30        for property in properties {
31            property.changed().subscribe(|e| {
32                if let Some(sender) = e.sender().downcast_ref::<T>() {
33                    let effect: &Effect = sender.upcast();
34                    effect.raise_invalidated();
35                }
36            });
37        }
38    }
39
40    /// Raises the invalidated notification.
41    pub fn raise_invalidated(&self) {
42        if self.invalidated.is_empty() {
43            return;
44        }
45        for (_, handler) in self.invalidated.snapshot().iter() {
46            handler();
47        }
48    }
49
50    /// Subscribes to invalidation of the effect: raised when a change
51    /// requires the affected content to be redrawn. Disposing the returned
52    /// handle unsubscribes.
53    pub fn invalidated(&self, handler: impl Fn() + 'static) -> Rc<dyn IDisposable> {
54        let token = self.invalidated.add(Rc::new(handler));
55        let object: &FerroObject = self.upcast();
56        let weak = object.to_weak();
57        Disposable::create(move || {
58            if let Some(this) = weak.upgrade() {
59                if let Some(this) = this.downcast_ref::<Effect>() {
60                    this.invalidated.remove(token);
61                }
62            }
63        })
64    }
65
66    fn parse_error(s: &str) -> FormatError {
67        FormatError::from_string(format!("Unable to parse effect: {s}"))
68    }
69
70    /// Parses an effect: `blur(<radius>)` or
71    /// `drop-shadow(<offset-x> <offset-y> [<blur-radius> [<color>]])`.
72    pub fn parse(s: &str) -> Result<Rc<dyn IEffect>, FormatError> {
73        let mut r = TokenParser::new(s);
74        if r.try_consume_str("blur") {
75            let radius = match (r.try_consume('('), r.try_parse_double()) {
76                (true, Some(radius)) if r.try_consume(')') && r.is_eof_with_whitespace() => radius,
77                _ => return Err(Self::parse_error(s)),
78            };
79            return Ok(Rc::new(ImmutableBlurEffect::new(radius)));
80        }
81
82        if r.try_consume_str("drop-shadow") {
83            if !r.try_consume('(') {
84                return Err(Self::parse_error(s));
85            }
86            let Some(offset_x) = r.try_parse_double() else { return Err(Self::parse_error(s)) };
87            let Some(offset_y) = r.try_parse_double() else { return Err(Self::parse_error(s)) };
88            let mut blur_radius = 0.0;
89            let color = Colors::BLACK;
90            if !r.try_consume(')') {
91                match r.try_parse_double() {
92                    Some(value) if value >= 0.0 => blur_radius = value,
93                    _ => return Err(Self::parse_error(s)),
94                }
95
96                if !r.try_consume(')') {
97                    let Some(end_of_expression) = s.rfind(')') else { return Err(Self::parse_error(s)) };
98
99                    if !TokenParser::new(&s[end_of_expression + 1..]).is_eof_with_whitespace() {
100                        return Err(Self::parse_error(s));
101                    }
102
103                    if end_of_expression < r.position() {
104                        return Err(Self::parse_error(s));
105                    }
106                    let Some(color) = Color::try_parse(s[r.position()..end_of_expression].trim_end()) else {
107                        return Err(Self::parse_error(s));
108                    };
109
110                    return Ok(Rc::new(ImmutableDropShadowEffect::new(offset_x, offset_y, blur_radius, color, 1.0)));
111                }
112            }
113            if !r.is_eof_with_whitespace() {
114                return Err(Self::parse_error(s));
115            }
116            return Ok(Rc::new(ImmutableDropShadowEffect::new(offset_x, offset_y, blur_radius, color, 1.0)));
117        }
118
119        Err(Self::parse_error(s))
120    }
121}
122
123/// The subset of the composition expression token parser that effect parsing
124/// uses.
125struct TokenParser<'a> {
126    s: &'a str,
127    position: usize,
128}
129
130impl<'a> TokenParser<'a> {
131    fn new(s: &'a str) -> Self {
132        Self { s, position: 0 }
133    }
134
135    /// The offset of the next unread byte in the parsed string.
136    fn position(&self) -> usize {
137        self.position
138    }
139
140    fn skip_whitespace(&mut self) {
141        let trimmed = self.s.trim_start_matches(char::is_whitespace);
142        self.advance(self.s.len() - trimmed.len());
143    }
144
145    fn try_consume(&mut self, c: char) -> bool {
146        self.skip_whitespace();
147        if !self.s.starts_with(c) {
148            return false;
149        }
150        self.advance(c.len_utf8());
151        true
152    }
153
154    fn try_consume_str(&mut self, s: &str) -> bool {
155        self.skip_whitespace();
156        if !self.s.starts_with(s) {
157            return false;
158        }
159        self.advance(s.len());
160        true
161    }
162
163    fn advance(&mut self, c: usize) {
164        self.s = &self.s[c..];
165        self.position += c;
166    }
167
168    fn try_parse_double(&mut self) -> Option<f64> {
169        self.skip_whitespace();
170        if self.s.is_empty() {
171            return None;
172        }
173        let mut len = 0;
174        let mut dot_count = 0;
175        for (c, ch) in self.s.bytes().enumerate() {
176            if ch.is_ascii_digit() {
177                len = c + 1;
178            } else if ch == b'.' && dot_count == 0 {
179                len = c + 1;
180                dot_count += 1;
181            } else if ch == b'-' {
182                if len != 0 {
183                    return None;
184                }
185                len = c + 1;
186            } else {
187                break;
188            }
189        }
190
191        let span = &self.s[..len];
192        // A lone sign or decimal point is not a number.
193        if !span.bytes().any(|b| b.is_ascii_digit()) {
194            return None;
195        }
196        let res = span.parse::<f64>().ok()?;
197        self.advance(len);
198        Some(res)
199    }
200
201    fn is_eof_with_whitespace(&mut self) -> bool {
202        self.skip_whitespace();
203        self.s.is_empty()
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::media::effects::EffectExtensions;
211
212    #[test]
213    fn parse_parses_blur() {
214        let effect = Effect::parse("blur(123.34)").unwrap();
215        let effect = effect.as_any().downcast_ref::<ImmutableBlurEffect>().unwrap();
216        assert_eq!(123.34, effect.radius());
217    }
218
219    const BLACK: u32 = 0xff000000;
220
221    #[test]
222    fn parse_parses_drop_shadow() {
223        for (s, x, y, r, color) in [
224            ("drop-shadow(10 20)", 10.0, 20.0, 0.0, BLACK),
225            ("drop-shadow( 10  20 ) ", 10.0, 20.0, 0.0, BLACK),
226            ("drop-shadow( 10  20 30 ) ", 10.0, 20.0, 30.0, BLACK),
227            ("drop-shadow(10  20 30)", 10.0, 20.0, 30.0, BLACK),
228            ("drop-shadow(-10  -20 30)", -10.0, -20.0, 30.0, BLACK),
229            ("drop-shadow(10 20 30 #ffff00ff)", 10.0, 20.0, 30.0, 0xffff00ff),
230            ("drop-shadow ( 10 20 30 #ffff00ff ) ", 10.0, 20.0, 30.0, 0xffff00ff),
231            ("drop-shadow(10 20 30 red)", 10.0, 20.0, 30.0, 0xffff0000),
232            ("drop-shadow ( 10   20   30 red  ) ", 10.0, 20.0, 30.0, 0xffff0000),
233            ("drop-shadow(10 20 30 rgba(100, 30, 45, 90%))", 10.0, 20.0, 30.0, 0xe6641e2d),
234            ("drop-shadow(10 20 30  rgba(100, 30, 45, 90%) ) ", 10.0, 20.0, 30.0, 0xe6641e2d),
235        ] {
236            let effect = Effect::parse(s).unwrap_or_else(|e| panic!("{s}: {e}"));
237            let effect = effect.as_any().downcast_ref::<ImmutableDropShadowEffect>().unwrap();
238            assert_eq!(x, effect.offset_x(), "{s}");
239            assert_eq!(y, effect.offset_y(), "{s}");
240            assert_eq!(r, effect.blur_radius(), "{s}");
241            assert_eq!(1.0, effect.opacity(), "{s}");
242            assert_eq!(color, effect.color().to_uint32(), "{s}");
243        }
244    }
245
246    #[test]
247    fn invalid_effect_parse_fails() {
248        for b in ["blur", "blur(", "blur()", "blur(123", "blur(aaab)", "drop-shadow(-10  -20 -30)"] {
249            assert!(Effect::parse(b).is_err(), "{b}");
250        }
251    }
252
253    #[test]
254    fn padding_is_correctly_calculated() {
255        for (effect, left, top, right, bottom) in [
256            ("blur(2.5)", 4.0, 4.0, 4.0, 4.0),
257            ("blur(0)", 0.0, 0.0, 0.0, 0.0),
258            ("drop-shadow(10 15)", 0.0, 0.0, 10.0, 15.0),
259            ("drop-shadow(10 15 5)", 0.0, 0.0, 16.0, 21.0),
260            ("drop-shadow(0 0 5)", 6.0, 6.0, 6.0, 6.0),
261            ("drop-shadow(3 3 5)", 3.0, 3.0, 9.0, 9.0),
262        ] {
263            let parsed = Effect::parse(effect).unwrap();
264            let padding = EffectExtensions::get_effect_output_padding(Some(&*parsed));
265            assert_eq!(left, padding.left, "{effect}");
266            assert_eq!(top, padding.top, "{effect}");
267            assert_eq!(right, padding.right, "{effect}");
268            assert_eq!(bottom, padding.bottom, "{effect}");
269        }
270    }
271
272    // --- not from upstream ---
273
274    #[test]
275    fn parse_rejects_lone_sign_and_trailing_garbage() {
276        for s in ["blur(-)", "blur(.)", "blur(1) x", "drop-shadow(1 2 3 red) x", "drop-shadow(1 2 3 nocolor)", ""] {
277            assert!(Effect::parse(s).is_err(), "{s}");
278        }
279    }
280}