Skip to main content

ferroui_controls/
expander.rs

1use crate::metadata::PseudoClassesAttribute;
2use crate::primitives::{HeaderedContentControl, TemplatedControlImpl};
3use crate::{ContentControlImpl, ControlImpl};
4use ferroui_base::animation::IPageTransition;
5use ferroui_base::data::{BindingMode, BindingPriority};
6use ferroui_base::input::InputElementImpl;
7use ferroui_base::interactivity::{
8    CancelRoutedEventArgs, Interactive, InteractiveImpl, RoutedEvent, RoutedEventArgs, RoutedEventHandlerToken,
9    RoutingStrategies,
10};
11use ferroui_base::layout::LayoutableImpl;
12use ferroui_base::threading::{
13    CancellationTokenSource, Dispatcher, DispatcherPriority, FerroSynchronizationContext,
14};
15use ferroui_base::{
16    ferro_class, ferro_impl_classes, ferro_property, ferro_routed_event, instantiate, FerroObject, FerroObjectImpl,
17    FerroObjectImplExt, FerroProperty, FerroPropertyChangedEventArgs, Ref, StyledElementImpl, StyledProperty,
18    StyledPropertyOptions, Visual, VisualImpl,
19};
20use std::cell::{Cell, RefCell};
21use std::rc::Rc;
22
23const PC_EXPANDED: &str = ":expanded";
24const PC_UP: &str = ":up";
25const PC_DOWN: &str = ":down";
26const PC_LEFT: &str = ":left";
27const PC_RIGHT: &str = ":right";
28
29/// Direction in which an [`Expander`] control opens.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31#[repr(i32)]
32pub enum ExpandDirection {
33    /// Opens down.
34    Down = 0,
35    /// Opens up.
36    Up = 1,
37    /// Opens left.
38    Left = 2,
39    /// Opens right.
40    Right = 3,
41}
42
43/// A control with a header that has a collapsible content section.
44#[repr(C)]
45pub struct Expander {
46    base: HeaderedContentControl,
47    ignore_property_changed: Cell<bool>,
48    last_transition_cts: RefCell<Option<CancellationTokenSource>>,
49}
50
51ferro_class! {
52    Expander: HeaderedContentControl, virtuals ExpanderImpl: ContentControlImpl {
53        /// Raises the `Collapsed` event.
54        fn on_collapsed(this, event_args: &RoutedEventArgs);
55        /// Raises the `Collapsing` event.
56        fn on_collapsing(this, event_args: &CancelRoutedEventArgs);
57        /// Raises the `Expanded` event.
58        fn on_expanded(this, event_args: &RoutedEventArgs);
59        /// Raises the `Expanding` event.
60        fn on_expanding(this, event_args: &CancelRoutedEventArgs);
61        /// Called when the `IsExpanded` property has to be coerced.
62        fn on_coerce_is_expanded(this, value: bool) -> bool;
63    }
64}
65ferroui_base::ferro_class_info!(Expander { new: Expander::new });
66
67ferro_impl_classes!(
68    Expander: StyledElementImpl,
69    VisualImpl,
70    LayoutableImpl,
71    InteractiveImpl,
72    InputElementImpl,
73    TemplatedControlImpl,
74    ContentControlImpl
75);
76
77impl ControlImpl for Expander {
78    fn on_create_automation_peer(this: &Self) -> Ref<crate::automation::peers::AutomationPeer> {
79        crate::automation::peers::ExpanderAutomationPeer::new(this).upcast()
80    }
81}
82
83impl FerroObjectImpl for Expander {
84    fn constructed(this: &Self) {
85        Self::parent_constructed(this);
86        this.update_pseudo_classes();
87    }
88
89    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
90        Self::parent_on_property_changed(this, change);
91
92        if this.ignore_property_changed.get() {
93            return;
94        }
95
96        if change.property() == Self::expand_direction_property().as_property() {
97            this.update_pseudo_classes();
98        } else if change.property() == Self::is_expanded_property().as_property() {
99            // Expanded/Collapsed will be raised once transitions are complete.
100            this.start_content_transition();
101
102            this.update_pseudo_classes();
103        }
104    }
105}
106
107impl ExpanderImpl for Expander {
108    fn on_collapsed(this: &Self, event_args: &RoutedEventArgs) {
109        this.raise_event(event_args);
110    }
111
112    fn on_collapsing(this: &Self, event_args: &CancelRoutedEventArgs) {
113        this.raise_event(event_args);
114    }
115
116    fn on_expanded(this: &Self, event_args: &RoutedEventArgs) {
117        this.raise_event(event_args);
118    }
119
120    fn on_expanding(this: &Self, event_args: &CancelRoutedEventArgs) {
121        this.raise_event(event_args);
122    }
123
124    fn on_coerce_is_expanded(this: &Self, value: bool) -> bool {
125        let event_args = if value {
126            let event_args = CancelRoutedEventArgs::with_event_and_source(Self::expanding_event(), this.to_ref());
127            this.on_expanding(&event_args);
128            event_args
129        } else {
130            let event_args = CancelRoutedEventArgs::with_event_and_source(Self::collapsing_event(), this.to_ref());
131            this.on_collapsing(&event_args);
132            event_args
133        };
134
135        if event_args.cancel() {
136            // If the event was externally canceled we must still notify the
137            // value has changed. This property changed notification will
138            // update any external code observing this property that itself
139            // may have set the new value. We are essentially reverting any
140            // external state change along with ignoring the `IsExpanded`
141            // property set. Remember `IsExpanded` is usually controlled by a
142            // toggle button in the control theme and is also used for
143            // animations.
144            this.ignore_property_changed.set(true);
145
146            this.raise_property_changed(
147                Self::is_expanded_property().as_property(),
148                Some(&value),
149                &!value,
150                BindingPriority::LocalValue,
151                true,
152            );
153
154            this.ignore_property_changed.set(false);
155
156            return !value;
157        }
158
159        value
160    }
161}
162
163impl Expander {
164    /// The pseudoclasses set by the class.
165    pub const PSEUDO_CLASSES: PseudoClassesAttribute =
166        PseudoClassesAttribute::new(&[PC_EXPANDED, PC_UP, PC_DOWN, PC_LEFT, PC_RIGHT]);
167}
168
169ferroui_base::ferro_properties! { impl Expander {
170    ferro_property!(
171        /// Defines the `ContentTransition` property.
172        pub fn content_transition_property() -> StyledProperty<Option<Rc<dyn IPageTransition>>> {
173            FerroProperty::register::<Expander, _>("ContentTransition", None)
174        }
175    );
176
177    ferro_property!(
178        /// Defines the `ExpandDirection` property.
179        pub fn expand_direction_property() -> StyledProperty<ExpandDirection> {
180            FerroProperty::register::<Expander, _>("ExpandDirection", ExpandDirection::Down)
181        }
182    );
183
184    ferro_property!(
185        /// Defines the `IsExpanded` property.
186        pub fn is_expanded_property() -> StyledProperty<bool> {
187            FerroProperty::register_with::<Expander, _>(
188                "IsExpanded",
189                StyledPropertyOptions::new(false)
190                    .default_binding_mode(BindingMode::TwoWay)
191                    .coerce(Expander::coerce_is_expanded),
192            )
193        }
194    );
195} }
196
197impl Expander {
198    ferro_routed_event!(
199        /// Defines the `Collapsed` event.
200        pub fn collapsed_event() -> RoutedEvent<RoutedEventArgs> {
201            RoutedEvent::register::<Expander, _>("Collapsed", RoutingStrategies::BUBBLE)
202        }
203    );
204
205    ferro_routed_event!(
206        /// Defines the `Collapsing` event.
207        pub fn collapsing_event() -> RoutedEvent<CancelRoutedEventArgs> {
208            RoutedEvent::register::<Expander, _>("Collapsing", RoutingStrategies::BUBBLE)
209        }
210    );
211
212    ferro_routed_event!(
213        /// Defines the `Expanded` event.
214        pub fn expanded_event() -> RoutedEvent<RoutedEventArgs> {
215            RoutedEvent::register::<Expander, _>("Expanded", RoutingStrategies::BUBBLE)
216        }
217    );
218
219    ferro_routed_event!(
220        /// Defines the `Expanding` event.
221        pub fn expanding_event() -> RoutedEvent<CancelRoutedEventArgs> {
222            RoutedEvent::register::<Expander, _>("Expanding", RoutingStrategies::BUBBLE)
223        }
224    );
225
226    /// Creates the class data; see [`ferroui_base::FerroObject::construct`].
227    pub fn construct() -> Self {
228        Self {
229            base: HeaderedContentControl::construct(),
230            ignore_property_changed: Cell::new(false),
231            last_transition_cts: RefCell::new(None),
232        }
233    }
234
235    pub fn new() -> Ref<Self> {
236        instantiate(Self::construct())
237    }
238
239    /// The transition used when expanding or collapsing the content.
240    pub fn content_transition(&self) -> Option<Rc<dyn IPageTransition>> {
241        self.get_value(Self::content_transition_property())
242    }
243
244    pub fn set_content_transition(&self, value: Option<Rc<dyn IPageTransition>>) {
245        self.set_value(Self::content_transition_property(), value)
246    }
247
248    /// The direction in which the expander opens.
249    pub fn expand_direction(&self) -> ExpandDirection {
250        self.get_value(Self::expand_direction_property())
251    }
252
253    pub fn set_expand_direction(&self, value: ExpandDirection) {
254        self.set_value(Self::expand_direction_property(), value)
255    }
256
257    /// Whether the content area of the expander is open and visible.
258    pub fn is_expanded(&self) -> bool {
259        self.get_value(Self::is_expanded_property())
260    }
261
262    pub fn set_is_expanded(&self, value: bool) {
263        self.set_value(Self::is_expanded_property(), value)
264    }
265
266    /// Occurs after the content area has closed and only the header is
267    /// visible.
268    pub fn collapsed(&self, handler: impl Fn(&Interactive, &RoutedEventArgs) + 'static) -> RoutedEventHandlerToken {
269        self.add_handler(Self::collapsed_event(), handler)
270    }
271
272    /// Occurs as the content area is closing.
273    ///
274    /// The `cancel` property of the event args may be set to true to cancel
275    /// the event and keep the control open (expanded).
276    pub fn collapsing(
277        &self,
278        handler: impl Fn(&Interactive, &CancelRoutedEventArgs) + 'static,
279    ) -> RoutedEventHandlerToken {
280        self.add_handler(Self::collapsing_event(), handler)
281    }
282
283    /// Occurs after the expander has opened to display both its header and
284    /// content.
285    pub fn expanded(&self, handler: impl Fn(&Interactive, &RoutedEventArgs) + 'static) -> RoutedEventHandlerToken {
286        self.add_handler(Self::expanded_event(), handler)
287    }
288
289    /// Occurs as the content area is opening.
290    ///
291    /// The `cancel` property of the event args may be set to true to cancel
292    /// the event and keep the control closed (collapsed).
293    pub fn expanding(
294        &self,
295        handler: impl Fn(&Interactive, &CancelRoutedEventArgs) + 'static,
296    ) -> RoutedEventHandlerToken {
297        self.add_handler(Self::expanding_event(), handler)
298    }
299
300    /// Starts the content transition (if set) and invokes the `Expanded` and
301    /// `Collapsed` events when completed.
302    fn start_content_transition(&self) {
303        let this = self.to_ref();
304
305        // Expanded/Collapsed events are invoked asynchronously to ensure
306        // other events, such as Click, have time to complete first.
307        let post_completed = move || {
308            Dispatcher::ui_thread().post_local(
309                move || {
310                    if this.is_expanded() {
311                        this.on_expanded(&RoutedEventArgs::with_event_and_source(Self::expanded_event(), this.clone()));
312                    } else {
313                        this.on_collapsed(&RoutedEventArgs::with_event_and_source(
314                            Self::collapsed_event(),
315                            this.clone(),
316                        ));
317                    }
318                },
319                DispatcherPriority::DEFAULT,
320            );
321        };
322
323        let content_transition = self.content_transition();
324        let visual_content: Option<Ref<Visual>> = self.presenter().map(Ref::upcast);
325
326        if let (Some(_), Some(content_transition), Some(visual_content)) =
327            (self.content(), content_transition, visual_content)
328        {
329            let expand_direction = self.expand_direction();
330            let forward = expand_direction == ExpandDirection::Left || expand_direction == ExpandDirection::Up;
331
332            let cts = CancellationTokenSource::new();
333            let token = cts.token();
334            let last = self.last_transition_cts.replace(Some(cts));
335            if let Some(last) = last {
336                last.cancel();
337            }
338
339            let task = if self.is_expanded() {
340                content_transition.start(None, Some(&visual_content), forward, token)
341            } else {
342                content_transition.start(Some(&visual_content), None, !forward, token)
343            };
344
345            let context = FerroSynchronizationContext::current().unwrap_or_else(|| {
346                FerroSynchronizationContext::with_dispatcher(
347                    &Dispatcher::current_dispatcher(),
348                    DispatcherPriority::NORMAL,
349                )
350            });
351            drop(context.to_task_scheduler().start_local(async move {
352                if task.await.is_ok() {
353                    post_completed();
354                }
355            }));
356        } else {
357            post_completed();
358        }
359    }
360
361    /// Updates the visual state of the control by applying the latest
362    /// pseudoclasses.
363    fn update_pseudo_classes(&self) {
364        let expand_direction = self.expand_direction();
365
366        self.pseudo_classes().set(PC_UP, expand_direction == ExpandDirection::Up);
367        self.pseudo_classes().set(PC_DOWN, expand_direction == ExpandDirection::Down);
368        self.pseudo_classes().set(PC_LEFT, expand_direction == ExpandDirection::Left);
369        self.pseudo_classes().set(PC_RIGHT, expand_direction == ExpandDirection::Right);
370
371        self.pseudo_classes().set(PC_EXPANDED, self.is_expanded());
372    }
373
374    /// Coerces/validates the `IsExpanded` property value.
375    fn coerce_is_expanded(instance: &FerroObject, value: bool) -> bool {
376        if let Some(expander) = instance.downcast_ref::<Expander>() {
377            return expander.on_coerce_is_expanded(value);
378        }
379
380        value
381    }
382}