Skip to main content

ferroui_base/controls/
classes.rs

1use crate::collections::{CollectionChangedHandler, FerroList};
2use crate::utilities::HandlerList;
3use std::rc::Rc;
4
5/// Exposes an interface for setting pseudoclasses on a [`Classes`] collection.
6pub trait IPseudoClasses {
7    /// Adds a pseudoclass to the collection.
8    fn add_pseudo(&self, name: &str);
9    /// Removes a pseudoclass from the collection.
10    fn remove_pseudo(&self, name: &str) -> bool;
11    /// Returns whether a pseudoclass is present in the collection.
12    fn contains(&self, name: &str) -> bool;
13}
14
15/// Holds a collection of style classes for a styled element.
16///
17/// Similar to CSS, each control may have any number of styling classes
18/// applied. Classes whose name starts with `:` are pseudoclasses and may only
19/// be added or removed by the control itself, through [`IPseudoClasses`].
20///
21/// The value is a shared handle: clones refer to the same collection, and
22/// handles compare by identity.
23#[derive(Clone, Default)]
24pub struct Classes(Rc<ClassesData>);
25
26#[derive(Default)]
27struct ClassesData {
28    items: FerroList<String>,
29    listeners: HandlerList<dyn Fn()>,
30}
31
32impl PartialEq for Classes {
33    fn eq(&self, other: &Self) -> bool {
34        self.ptr_eq(other)
35    }
36}
37
38impl std::fmt::Debug for Classes {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "Classes({})", self.snapshot().join(" "))
41    }
42}
43
44impl Classes {
45    /// Creates an empty collection.
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Creates a collection from the given class names.
51    pub fn from_names<S: Into<String>>(items: impl IntoIterator<Item = S>) -> Self {
52        Self(Rc::new(ClassesData {
53            items: FerroList::from_items(items.into_iter().map(Into::into)),
54            listeners: HandlerList::new(),
55        }))
56    }
57
58    /// Whether two handles refer to the same collection.
59    pub fn ptr_eq(&self, other: &Self) -> bool {
60        Rc::ptr_eq(&self.0, &other.0)
61    }
62
63    /// Parses a space-separated list of class names.
64    pub fn parse(s: &str) -> Classes {
65        Self::from_names(s.split(' '))
66    }
67
68    /// The number of class names the collection can hold before its storage
69    /// grows.
70    pub fn capacity(&self) -> usize {
71        self.0.items.capacity()
72    }
73
74    /// Sets the number of class names the collection can hold before its
75    /// storage grows.
76    ///
77    /// # Panics
78    /// Panics if `capacity` is less than the number of class names.
79    pub fn set_capacity(&self, capacity: usize) {
80        self.0.items.set_capacity(capacity);
81    }
82
83    /// The number of classes.
84    pub fn count(&self) -> usize {
85        self.0.items.count()
86    }
87
88    /// The class at `index`.
89    pub fn get(&self, index: usize) -> String {
90        self.0.items.get(index)
91    }
92
93    /// A snapshot of the classes.
94    pub fn snapshot(&self) -> Rc<Vec<String>> {
95        self.0.items.snapshot()
96    }
97
98    /// Returns whether a class is present in the collection.
99    pub fn contains(&self, name: &str) -> bool {
100        self.0.items.snapshot().iter().any(|c| c == name)
101    }
102
103    /// Subscribes to collection changes; see [`FerroList::add_collection_changed`].
104    pub fn add_collection_changed(&self, handler: Rc<CollectionChangedHandler<String>>) -> u64 {
105        self.0.items.add_collection_changed(handler)
106    }
107
108    pub fn remove_collection_changed(&self, token: u64) -> bool {
109        self.0.items.remove_collection_changed(token)
110    }
111
112    /// Adds a style class to the collection. Only standard classes may be
113    /// added via this method.
114    pub fn add(&self, name: &str) {
115        Self::panic_if_pseudoclass(name, "added");
116        if !self.contains(name) {
117            self.0.items.add(name.to_string());
118            self.notify_changed();
119        }
120    }
121
122    /// Adds style classes to the collection.
123    pub fn add_range<S: AsRef<str>>(&self, names: impl IntoIterator<Item = S>) {
124        let mut to_add: Vec<String> = Vec::new();
125        for name in names {
126            let name = name.as_ref();
127            Self::panic_if_pseudoclass(name, "added");
128            if !self.contains(name) {
129                to_add.push(name.to_string());
130            }
131        }
132        self.0.items.add_range(to_add);
133        self.notify_changed();
134    }
135
136    /// Removes all non-pseudoclasses from the collection.
137    pub fn clear(&self) {
138        let mut i = self.0.items.count();
139        while i > 0 {
140            i -= 1;
141            if !self.0.items.get(i).starts_with(':') {
142                self.0.items.remove_at(i);
143            }
144        }
145        self.notify_changed();
146    }
147
148    /// Inserts a style class into the collection.
149    pub fn insert(&self, index: usize, name: &str) {
150        Self::panic_if_pseudoclass(name, "added");
151        if !self.contains(name) {
152            self.0.items.insert(index, name.to_string());
153            self.notify_changed();
154        }
155    }
156
157    /// Inserts style classes into the collection.
158    pub fn insert_range<S: AsRef<str>>(&self, index: usize, names: impl IntoIterator<Item = S>) {
159        let mut to_insert: Vec<String> = Vec::new();
160        for name in names {
161            let name = name.as_ref();
162            Self::panic_if_pseudoclass(name, "added");
163            if !self.contains(name) {
164                to_insert.push(name.to_string());
165            }
166        }
167        if !to_insert.is_empty() {
168            self.0.items.insert_range(index, to_insert);
169            self.notify_changed();
170        }
171    }
172
173    /// Removes a style class from the collection.
174    pub fn remove(&self, name: &str) -> bool {
175        Self::panic_if_pseudoclass(name, "removed");
176        if self.0.items.remove(&name.to_string()) {
177            self.notify_changed();
178            true
179        } else {
180            false
181        }
182    }
183
184    /// Removes style classes from the collection.
185    pub fn remove_all<S: AsRef<str>>(&self, names: impl IntoIterator<Item = S>) {
186        let mut to_remove: Vec<String> = Vec::new();
187        for name in names {
188            Self::panic_if_pseudoclass(name.as_ref(), "removed");
189            to_remove.push(name.as_ref().to_string());
190        }
191        if !to_remove.is_empty() {
192            self.0.items.remove_all(to_remove);
193            self.notify_changed();
194        }
195    }
196
197    /// Removes the style class at the specified index.
198    pub fn remove_at(&self, index: usize) {
199        let name = self.0.items.get(index);
200        Self::panic_if_pseudoclass(&name, "removed");
201        self.0.items.remove_at(index);
202        self.notify_changed();
203    }
204
205    /// Removes a range of style classes from the collection.
206    pub fn remove_range(&self, index: usize, count: usize) {
207        self.0.items.remove_range(index, count);
208        self.notify_changed();
209    }
210
211    /// Removes all non-pseudoclasses in the collection and adds a new set.
212    pub fn replace<S: AsRef<str>>(&self, source: &[S]) {
213        for name in source {
214            Self::panic_if_pseudoclass(name.as_ref(), "added");
215        }
216        let to_remove: Vec<String> = self.0.items.snapshot().iter().filter(|n| !n.starts_with(':')).cloned().collect();
217        if !to_remove.is_empty() {
218            self.0.items.remove_all(to_remove);
219        }
220        self.0.items.add_range(source.iter().map(|s| s.as_ref().to_string()));
221        self.notify_changed();
222    }
223
224    /// Adds or removes a style class.
225    pub fn set(&self, name: &str, value: bool) {
226        if value {
227            if !self.contains(name) {
228                self.add(name);
229            }
230        } else {
231            self.remove(name);
232        }
233    }
234
235    /// Registers a listener invoked whenever the classes change. Returns a
236    /// token for [`remove_listener`](Self::remove_listener).
237    pub fn add_listener(&self, listener: Rc<dyn Fn()>) -> u64 {
238        self.0.listeners.add(listener)
239    }
240
241    pub fn remove_listener(&self, token: u64) -> bool {
242        self.0.listeners.remove(token)
243    }
244
245    /// The number of registered listeners.
246    pub fn listener_count(&self) -> usize {
247        self.0.listeners.len()
248    }
249
250    fn notify_changed(&self) {
251        if self.0.listeners.is_empty() {
252            return;
253        }
254        for (_, listener) in self.0.listeners.snapshot().iter() {
255            listener();
256        }
257    }
258
259    fn panic_if_pseudoclass(name: &str, operation: &str) {
260        if name.starts_with(':') {
261            panic!("The pseudoclass '{name}' may only be {operation} by the control itself.");
262        }
263    }
264}
265
266impl IPseudoClasses for Classes {
267    fn add_pseudo(&self, name: &str) {
268        if !Classes::contains(self, name) {
269            self.0.items.add(name.to_string());
270            self.notify_changed();
271        }
272    }
273
274    fn remove_pseudo(&self, name: &str) -> bool {
275        // `FerroList::remove` of the name (the first equal item), without
276        // making a `String` of it for every call.
277        let index = self.0.items.snapshot().iter().position(|c| c == name);
278        if let Some(index) = index {
279            self.0.items.remove_at(index);
280            self.notify_changed();
281            true
282        } else {
283            false
284        }
285    }
286
287    fn contains(&self, name: &str) -> bool {
288        Classes::contains(self, name)
289    }
290}
291
292impl dyn IPseudoClasses + '_ {
293    /// Adds or removes a pseudoclass depending on a boolean value.
294    pub fn set(&self, name: &str, value: bool) -> bool {
295        if value {
296            self.add_pseudo(name);
297        } else {
298            self.remove_pseudo(name);
299        }
300        value
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::cell::Cell;
308
309    #[test]
310    fn duplicates_should_not_be_added() {
311        let target = Classes::new();
312        target.add("foo");
313        target.add("foo");
314        assert_eq!(*target.snapshot(), vec!["foo".to_string()]);
315    }
316
317    #[test]
318    #[should_panic]
319    fn should_not_be_able_to_add_pseudoclass() {
320        Classes::new().add(":foo");
321    }
322
323    #[test]
324    fn should_be_able_to_add_pseudoclass_through_interface() {
325        let target = Classes::new();
326        target.add_pseudo(":foo");
327        assert_eq!(*target.snapshot(), vec![":foo".to_string()]);
328    }
329
330    #[test]
331    fn clear_should_not_remove_pseudoclasses() {
332        let target = Classes::new();
333        target.add("foo");
334        target.add_pseudo(":bar");
335        target.add("baz");
336        target.clear();
337        assert_eq!(*target.snapshot(), vec![":bar".to_string()]);
338    }
339
340    #[test]
341    fn replace_should_not_replace_pseudoclasses() {
342        let target = Classes::new();
343        target.add("foo");
344        target.add("bar");
345        target.add_pseudo(":baz");
346        target.replace(&["qux"]);
347        assert_eq!(*target.snapshot(), vec![":baz".to_string(), "qux".to_string()]);
348    }
349
350    #[test]
351    fn listeners_are_notified() {
352        let target = Classes::new();
353        let count = Rc::new(Cell::new(0));
354        let c = count.clone();
355        target.add_listener(Rc::new(move || c.set(c.get() + 1)));
356        target.add("foo");
357        target.remove("foo");
358        assert_eq!(count.get(), 2);
359    }
360}