Skip to main content

ferroui_controls/date_time_pickers/
date_picker.rs

1use super::{DatePickerPresenter, DatePickerSelectedValueChangedEventArgs, PickerPresenterBase};
2use crate::primitives::popup_positioning::{PopupAnchor, PopupGravity, PopupPositionerConstraintAdjustment};
3use crate::primitives::{Popup, TemplateAppliedEventArgs, TemplatedControl, TemplatedControlImpl, TemplatedControlImplExt};
4use crate::shapes::Rectangle;
5use crate::{Button, ColumnDefinition, ContentControl, ControlImpl, Grid, GridUnitType, PlacementMode, TextBlock};
6use ferroui_base::data::BindingMode;
7use ferroui_base::input::InputElementImpl;
8use ferroui_base::interactivity::{InteractiveImpl, RoutedEventHandlerToken};
9use ferroui_base::layout::{LayoutableImpl, VerticalAlignment};
10use ferroui_base::reactive::{Disposable, IDisposable};
11use ferroui_base::utilities::{CultureInfo, DateTimeOffset, HandlerList};
12use ferroui_base::{
13    ferro_class, ferro_class_info, ferro_impl_classes, ferro_properties, instantiate, FerroObject, FerroObjectImpl, FerroObjectImplExt,
14    FerroProperty, FerroPropertyChangedEventArgs, Ref, StyledElementImpl, StyledProperty, StyledPropertyOptions,
15    VisualImpl,
16};
17use std::cell::{Cell, RefCell};
18use std::rc::Rc;
19
20const PC_HAS_NO_DATE: &str = ":hasnodate";
21
22/// The index of the first UTF-16 unit of `pattern` that is `letter` in
23/// either case, or -1 (the ordinal, case-insensitive `IndexOf` of upstream).
24pub(super) fn index_of_ignore_case(pattern: &str, letter: char) -> i32 {
25    pattern
26        .encode_utf16()
27        .position(|unit| char::from_u32(u32::from(unit)).is_some_and(|c| c.eq_ignore_ascii_case(&letter)))
28        .map_or(-1, |index| index as i32)
29}
30
31/// A control to allow the user to select a date.
32#[repr(C)]
33pub struct DatePicker {
34    base: TemplatedControl,
35
36    // Template Items
37    flyout_button: RefCell<Option<Ref<Button>>>,
38    day_text: RefCell<Option<Ref<TextBlock>>>,
39    month_text: RefCell<Option<Ref<TextBlock>>>,
40    year_text: RefCell<Option<Ref<TextBlock>>>,
41    container: RefCell<Option<Ref<Grid>>>,
42    spacer1: RefCell<Option<Ref<Rectangle>>>,
43    spacer2: RefCell<Option<Ref<Rectangle>>>,
44    popup: RefCell<Option<Ref<Popup>>>,
45    presenter: RefCell<Option<Ref<DatePickerPresenter>>>,
46
47    /// The click handler of the flyout button, while it is added.
48    flyout_button_click: Cell<Option<RoutedEventHandlerToken>>,
49    /// The subscriptions to the confirmed and dismissed events of the
50    /// presenter.
51    presenter_subscriptions: RefCell<Option<(Rc<dyn IDisposable>, Rc<dyn IDisposable>)>>,
52
53    are_controls_available: Cell<bool>,
54
55    selected_date_changed: HandlerList<dyn Fn(&DatePickerSelectedValueChangedEventArgs)>,
56}
57
58ferro_class! {
59    DatePicker: TemplatedControl, virtuals DatePickerImpl: TemplatedControlImpl {
60        /// Raises the `SelectedDateChanged` event.
61        fn on_selected_date_changed(this, e: &DatePickerSelectedValueChangedEventArgs);
62    }
63}
64
65ferro_class_info!(DatePicker {
66    new: DatePicker::new,
67    markup: {
68        attributes: [
69            TemplatePart("PART_ButtonContentGrid", type(Ref<Grid>)),
70            TemplatePart("PART_DayTextBlock", type(Ref<TextBlock>)),
71            TemplatePart("PART_FirstSpacer", type(Ref<Rectangle>)),
72            TemplatePart("PART_FlyoutButton", type(Ref<Button>)),
73            TemplatePart("PART_MonthTextBlock", type(Ref<TextBlock>)),
74            TemplatePart("PART_PickerPresenter", type(Ref<DatePickerPresenter>)),
75            TemplatePart("PART_Popup", type(Ref<Popup>)),
76            TemplatePart("PART_SecondSpacer", type(Ref<Rectangle>)),
77            TemplatePart("PART_YearTextBlock", type(Ref<TextBlock>)),
78            PseudoClasses(":hasnodate"),
79        ],
80    },
81});
82
83ferro_impl_classes!(
84    DatePicker: StyledElementImpl,
85    VisualImpl,
86    LayoutableImpl,
87    InteractiveImpl,
88    InputElementImpl
89);
90
91impl FerroObjectImpl for DatePicker {
92    fn constructed(this: &Self) {
93        Self::parent_constructed(this);
94
95        this.pseudo_classes().set(PC_HAS_NO_DATE, true);
96        let now = DateTimeOffset::now();
97        this.set_current_value(
98            Self::min_year_property(),
99            DateTimeOffset::new(now.date().year() - 100, 1, 1, 0, 0, 0, now.offset()),
100        );
101        this.set_current_value(
102            Self::max_year_property(),
103            DateTimeOffset::new(now.date().year() + 100, 12, 31, 0, 0, 0, now.offset()),
104        );
105    }
106
107    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
108        Self::parent_on_property_changed(this, change);
109
110        let property = change.property();
111        if property == Self::day_visible_property().as_property()
112            || property == Self::month_visible_property().as_property()
113            || property == Self::year_visible_property().as_property()
114        {
115            this.set_grid();
116        } else if property == Self::max_year_property().as_property() {
117            this.on_max_year_changed(change.get_new_value::<DateTimeOffset>());
118        } else if property == Self::min_year_property().as_property() {
119            this.on_min_year_changed(change.get_new_value::<DateTimeOffset>());
120        } else if property == Self::selected_date_property().as_property() {
121            this.set_selected_date_text();
122
123            let (old_value, new_value) = change.get_old_and_new_value::<Option<DateTimeOffset>>();
124            this.on_selected_date_changed(&DatePickerSelectedValueChangedEventArgs::new(old_value, new_value));
125        } else if property == Self::month_format_property().as_property()
126            || property == Self::year_format_property().as_property()
127            || property == Self::day_format_property().as_property()
128        {
129            this.set_selected_date_text();
130        }
131    }
132}
133
134impl TemplatedControlImpl for DatePicker {
135    fn on_apply_template(this: &Self, e: &TemplateAppliedEventArgs) {
136        this.are_controls_available.set(false);
137        let flyout_button = this.flyout_button.borrow().clone();
138        if let (Some(flyout_button), Some(token)) = (flyout_button, this.flyout_button_click.take()) {
139            flyout_button.remove_handler(Button::click_event(), token);
140        }
141        if let Some((confirmed, dismissed)) = this.presenter_subscriptions.take() {
142            confirmed.dispose();
143            dismissed.dispose();
144        }
145
146        Self::parent_on_apply_template(this, e);
147        let name_scope = e.name_scope();
148        let flyout_button = name_scope.find_as::<Button>("PART_FlyoutButton");
149        *this.flyout_button.borrow_mut() = flyout_button.clone();
150        *this.day_text.borrow_mut() = name_scope.find_as::<TextBlock>("PART_DayTextBlock");
151        *this.month_text.borrow_mut() = name_scope.find_as::<TextBlock>("PART_MonthTextBlock");
152        *this.year_text.borrow_mut() = name_scope.find_as::<TextBlock>("PART_YearTextBlock");
153        *this.container.borrow_mut() = name_scope.find_as::<Grid>("PART_ButtonContentGrid");
154        *this.spacer1.borrow_mut() = name_scope.find_as::<Rectangle>("PART_FirstSpacer");
155        *this.spacer2.borrow_mut() = name_scope.find_as::<Rectangle>("PART_SecondSpacer");
156        *this.popup.borrow_mut() = name_scope.find_as::<Popup>("PART_Popup");
157        let presenter = name_scope.find_as::<DatePickerPresenter>("PART_PickerPresenter");
158        *this.presenter.borrow_mut() = presenter.clone();
159
160        this.are_controls_available.set(true);
161
162        this.set_grid();
163        this.set_selected_date_text();
164
165        if let Some(flyout_button) = flyout_button {
166            let weak = this.to_ref().downgrade();
167            let token = flyout_button.click(move |_, _| {
168                if let Some(this) = weak.upgrade() {
169                    this.on_flyout_button_clicked();
170                }
171            });
172            this.flyout_button_click.set(Some(token));
173        }
174
175        if let Some(presenter) = presenter {
176            let weak = this.to_ref().downgrade();
177            let confirmed = PickerPresenterBase::confirmed(&presenter, move || {
178                if let Some(this) = weak.upgrade() {
179                    this.on_confirmed();
180                }
181            });
182            let weak = this.to_ref().downgrade();
183            let dismissed = PickerPresenterBase::dismissed(&presenter, move || {
184                if let Some(this) = weak.upgrade() {
185                    this.on_dismiss_picker();
186                }
187            });
188            *this.presenter_subscriptions.borrow_mut() = Some((confirmed, dismissed));
189
190            let bind = |target: &'static FerroProperty, source: &'static FerroProperty| {
191                presenter.bind_indexer(&target.bind(), &this.indexer(&source.bind()));
192            };
193
194            bind(DatePickerPresenter::max_year_property().as_property(), Self::max_year_property().as_property());
195            bind(DatePickerPresenter::min_year_property().as_property(), Self::min_year_property().as_property());
196
197            bind(
198                DatePickerPresenter::month_visible_property().as_property(),
199                Self::month_visible_property().as_property(),
200            );
201            bind(
202                DatePickerPresenter::month_format_property().as_property(),
203                Self::month_format_property().as_property(),
204            );
205
206            bind(DatePickerPresenter::day_visible_property().as_property(), Self::day_visible_property().as_property());
207            bind(DatePickerPresenter::day_format_property().as_property(), Self::day_format_property().as_property());
208
209            bind(
210                DatePickerPresenter::year_visible_property().as_property(),
211                Self::year_visible_property().as_property(),
212            );
213            bind(DatePickerPresenter::year_format_property().as_property(), Self::year_format_property().as_property());
214        }
215    }
216}
217
218impl ControlImpl for DatePicker {
219    fn on_create_automation_peer(this: &Self) -> Ref<crate::automation::peers::AutomationPeer> {
220        crate::automation::peers::DatePickerAutomationPeer::new(this).upcast()
221    }
222}
223
224impl DatePickerImpl for DatePicker {
225    fn on_selected_date_changed(this: &Self, e: &DatePickerSelectedValueChangedEventArgs) {
226        for (_, handler) in this.selected_date_changed.snapshot().iter() {
227            handler(e);
228        }
229    }
230}
231
232ferro_properties! {
233    impl DatePicker {
234        /// Defines the `DayFormat` property.
235        pub fn day_format_property() -> StyledProperty<String> {
236            FerroProperty::register::<DatePicker, _>("DayFormat", "%d".to_string())
237        }
238
239        /// Defines the `DayVisible` property.
240        pub fn day_visible_property() -> StyledProperty<bool> {
241            FerroProperty::register::<DatePicker, _>("DayVisible", true)
242        }
243
244        /// Defines the `MaxYear` property.
245        pub fn max_year_property() -> StyledProperty<DateTimeOffset> {
246            FerroProperty::register_with::<DatePicker, _>(
247                "MaxYear",
248                StyledPropertyOptions::new(DateTimeOffset::MAX_VALUE).coerce(DatePicker::coerce_max_year),
249            )
250        }
251
252        /// Defines the `MinYear` property.
253        pub fn min_year_property() -> StyledProperty<DateTimeOffset> {
254            FerroProperty::register_with::<DatePicker, _>(
255                "MinYear",
256                StyledPropertyOptions::new(DateTimeOffset::MIN_VALUE).coerce(DatePicker::coerce_min_year),
257            )
258        }
259
260        /// Defines the `MonthFormat` property.
261        pub fn month_format_property() -> StyledProperty<String> {
262            FerroProperty::register::<DatePicker, _>("MonthFormat", "MMMM".to_string())
263        }
264
265        /// Defines the `MonthVisible` property.
266        pub fn month_visible_property() -> StyledProperty<bool> {
267            FerroProperty::register::<DatePicker, _>("MonthVisible", true)
268        }
269
270        /// Defines the `YearFormat` property.
271        pub fn year_format_property() -> StyledProperty<String> {
272            FerroProperty::register::<DatePicker, _>("YearFormat", "yyyy".to_string())
273        }
274
275        /// Defines the `YearVisible` property.
276        pub fn year_visible_property() -> StyledProperty<bool> {
277            FerroProperty::register::<DatePicker, _>("YearVisible", true)
278        }
279
280        /// Defines the `VerticalContentAlignment` property.
281        pub fn vertical_content_alignment_property() -> StyledProperty<VerticalAlignment> {
282            ContentControl::vertical_content_alignment_property().add_owner::<DatePicker>()
283        }
284
285        /// Defines the `SelectedDate` property.
286        pub fn selected_date_property() -> StyledProperty<Option<DateTimeOffset>> {
287            FerroProperty::register_with::<DatePicker, _>(
288                "SelectedDate",
289                StyledPropertyOptions::new(None)
290                    .default_binding_mode(BindingMode::TwoWay)
291                    .enable_data_validation(true),
292            )
293        }
294    }
295}
296
297impl DatePicker {
298    /// Creates the class data; see [`ferroui_base::FerroObject::construct`].
299    pub fn construct() -> Self {
300        Self {
301            base: TemplatedControl::construct(),
302            flyout_button: RefCell::new(None),
303            day_text: RefCell::new(None),
304            month_text: RefCell::new(None),
305            year_text: RefCell::new(None),
306            container: RefCell::new(None),
307            spacer1: RefCell::new(None),
308            spacer2: RefCell::new(None),
309            popup: RefCell::new(None),
310            presenter: RefCell::new(None),
311            flyout_button_click: Cell::new(None),
312            presenter_subscriptions: RefCell::new(None),
313            are_controls_available: Cell::new(false),
314            selected_date_changed: HandlerList::new(),
315        }
316    }
317
318    pub fn new() -> Ref<Self> {
319        instantiate(Self::construct())
320    }
321
322    /// The vertical alignment of the content within the control.
323    pub fn vertical_content_alignment(&self) -> VerticalAlignment {
324        self.get_value(Self::vertical_content_alignment_property())
325    }
326
327    pub fn set_vertical_content_alignment(&self, value: VerticalAlignment) {
328        self.set_value(Self::vertical_content_alignment_property(), value)
329    }
330
331    /// The format of the day.
332    pub fn day_format(&self) -> String {
333        self.get_value(Self::day_format_property())
334    }
335
336    pub fn set_day_format(&self, value: &str) {
337        self.set_value(Self::day_format_property(), value.to_string())
338    }
339
340    /// Whether the day is visible.
341    pub fn day_visible(&self) -> bool {
342        self.get_value(Self::day_visible_property())
343    }
344
345    pub fn set_day_visible(&self, value: bool) {
346        self.set_value(Self::day_visible_property(), value)
347    }
348
349    /// The maximum year for the picker.
350    pub fn max_year(&self) -> DateTimeOffset {
351        self.get_value(Self::max_year_property())
352    }
353
354    pub fn set_max_year(&self, value: DateTimeOffset) {
355        self.set_value(Self::max_year_property(), value)
356    }
357
358    fn coerce_max_year(sender: &FerroObject, value: DateTimeOffset) -> DateTimeOffset {
359        if value < sender.get_value(Self::min_year_property()) {
360            panic!("MaxYear cannot be less than MinYear");
361        }
362
363        value
364    }
365
366    fn on_max_year_changed(&self, value: DateTimeOffset) {
367        if self.selected_date().is_some_and(|selected_date| selected_date > value) {
368            self.set_current_value(Self::selected_date_property(), Some(value));
369        }
370    }
371
372    /// The minimum year for the picker.
373    pub fn min_year(&self) -> DateTimeOffset {
374        self.get_value(Self::min_year_property())
375    }
376
377    pub fn set_min_year(&self, value: DateTimeOffset) {
378        self.set_value(Self::min_year_property(), value)
379    }
380
381    fn coerce_min_year(sender: &FerroObject, value: DateTimeOffset) -> DateTimeOffset {
382        if value > sender.get_value(Self::max_year_property()) {
383            panic!("MinYear cannot be greater than MaxYear");
384        }
385
386        value
387    }
388
389    fn on_min_year_changed(&self, value: DateTimeOffset) {
390        if self.selected_date().is_some_and(|selected_date| selected_date < value) {
391            self.set_current_value(Self::selected_date_property(), Some(value));
392        }
393    }
394
395    /// The month format.
396    pub fn month_format(&self) -> String {
397        self.get_value(Self::month_format_property())
398    }
399
400    pub fn set_month_format(&self, value: &str) {
401        self.set_value(Self::month_format_property(), value.to_string())
402    }
403
404    /// Whether the month is visible.
405    pub fn month_visible(&self) -> bool {
406        self.get_value(Self::month_visible_property())
407    }
408
409    pub fn set_month_visible(&self, value: bool) {
410        self.set_value(Self::month_visible_property(), value)
411    }
412
413    /// The year format.
414    pub fn year_format(&self) -> String {
415        self.get_value(Self::year_format_property())
416    }
417
418    pub fn set_year_format(&self, value: &str) {
419        self.set_value(Self::year_format_property(), value.to_string())
420    }
421
422    /// Whether the year is visible.
423    pub fn year_visible(&self) -> bool {
424        self.get_value(Self::year_visible_property())
425    }
426
427    pub fn set_year_visible(&self, value: bool) {
428        self.set_value(Self::year_visible_property(), value)
429    }
430
431    /// The selected date for the picker; can be `None`.
432    pub fn selected_date(&self) -> Option<DateTimeOffset> {
433        self.get_value(Self::selected_date_property())
434    }
435
436    pub fn set_selected_date(&self, value: Option<DateTimeOffset>) {
437        self.set_value(Self::selected_date_property(), value)
438    }
439
440    /// Raised when the selected date changes. Disposing the returned handle
441    /// removes the handler.
442    pub fn selected_date_changed(
443        &self,
444        handler: impl Fn(&DatePickerSelectedValueChangedEventArgs) + 'static,
445    ) -> Rc<dyn IDisposable> {
446        let token = self.selected_date_changed.add(Rc::new(handler));
447        let weak = self.to_ref().downgrade();
448        Disposable::create(move || {
449            if let Some(this) = weak.upgrade() {
450                this.selected_date_changed.remove(token);
451            }
452        })
453    }
454
455    fn on_dismiss_picker(&self) {
456        let popup = self.popup.borrow().clone().expect("the template has a popup");
457        popup.close();
458        self.focus();
459    }
460
461    fn on_confirmed(&self) {
462        let popup = self.popup.borrow().clone().expect("the template has a popup");
463        popup.close();
464        let presenter = self.presenter.borrow().clone().expect("the template has a presenter");
465        self.set_current_value(Self::selected_date_property(), Some(presenter.date()));
466    }
467
468    fn set_grid(&self) {
469        let Some(container) = self.container.borrow().clone() else {
470            return;
471        };
472
473        let month_text = self.month_text.borrow().clone();
474        let year_text = self.year_text.borrow().clone();
475        let day_text = self.day_text.borrow().clone();
476
477        let date_time_format = CultureInfo::current_culture().date_time_format();
478        let fmt = date_time_format.short_date_pattern();
479        let mut columns = [
480            (month_text.clone(), if self.month_visible() { index_of_ignore_case(fmt, 'm') } else { -1 }),
481            (year_text, if self.year_visible() { index_of_ignore_case(fmt, 'y') } else { -1 }),
482            (day_text, if self.day_visible() { index_of_ignore_case(fmt, 'd') } else { -1 }),
483        ];
484
485        columns.sort_by_key(|column| column.1);
486        container.column_definitions().clear();
487
488        let mut column_index = 0;
489
490        for (text, index) in &columns {
491            let Some(text) = text else {
492                continue;
493            };
494
495            text.set_is_visible(*index != -1);
496
497            if *index != -1 {
498                if column_index > 0 {
499                    container.column_definitions().add(ColumnDefinition::with_value(0.0, GridUnitType::Auto));
500                }
501
502                let is_month = month_text.as_ref().is_some_and(|month_text| month_text.ptr_eq(text));
503                container
504                    .column_definitions()
505                    .add(ColumnDefinition::with_value(if is_month { 138.0 } else { 78.0 }, GridUnitType::Star));
506
507                if text.parent().is_none() {
508                    container.children().add(text.clone());
509                }
510
511                Grid::set_column(text, column_index * 2);
512                column_index += 1;
513            }
514        }
515
516        let is_spacer1_visible = column_index > 1;
517        let is_spacer2_visible = column_index > 2;
518        let spacer1 = self.spacer1.borrow().clone().expect("the template has a first spacer");
519        let spacer2 = self.spacer2.borrow().clone().expect("the template has a second spacer");
520        // The conditional is used to make sure grid cells will be validated
521        Grid::set_column(&spacer1, if is_spacer1_visible { 1 } else { 0 });
522        Grid::set_column(&spacer2, if is_spacer2_visible { 3 } else { 0 });
523
524        spacer1.set_is_visible(is_spacer1_visible);
525        spacer2.set_is_visible(is_spacer2_visible);
526    }
527
528    fn set_selected_date_text(&self) {
529        if !self.are_controls_available.get() {
530            return;
531        }
532
533        let month_text = self.month_text.borrow().clone().expect("the template has a month text block");
534        let year_text = self.year_text.borrow().clone().expect("the template has a year text block");
535        let day_text = self.day_text.borrow().clone().expect("the template has a day text block");
536
537        if let Some(sel_date) = self.selected_date() {
538            self.pseudo_classes().set(PC_HAS_NO_DATE, false);
539            month_text.set_text(Some(&sel_date.to_string_with(&self.month_format())));
540            year_text.set_text(Some(&sel_date.to_string_with(&self.year_format())));
541            day_text.set_text(Some(&sel_date.to_string_with(&self.day_format())));
542        } else {
543            // By clearing local value, we reset text property to the value from the template.
544            month_text.clear_value(TextBlock::text_property());
545            year_text.clear_value(TextBlock::text_property());
546            day_text.clear_value(TextBlock::text_property());
547            self.pseudo_classes().set(PC_HAS_NO_DATE, true);
548        }
549    }
550
551    fn on_flyout_button_clicked(&self) {
552        let Some(presenter) = self.presenter.borrow().clone() else {
553            panic!("No DatePickerPresenter found.");
554        };
555        let Some(popup) = self.popup.borrow().clone() else {
556            panic!("No Popup found.");
557        };
558
559        presenter.set_date(self.selected_date().unwrap_or_else(DateTimeOffset::now));
560
561        popup.set_placement(PlacementMode::AnchorAndGravity);
562        popup.set_placement_anchor(PopupAnchor::BOTTOM);
563        popup.set_placement_gravity(PopupGravity::BOTTOM);
564        popup.set_placement_constraint_adjustment(PopupPositionerConstraintAdjustment::SLIDE_Y);
565        popup.set_is_open(true);
566
567        // Overlay popup hosts won't get measured until the next layout pass, but we need the
568        // template to be applied to the presenter now. Detect this case and force a layout pass.
569        if !presenter.is_measure_valid() {
570            if let Some(layout_manager) = self.get_layout_manager() {
571                layout_manager.execute_initial_layout_pass();
572            }
573        }
574
575        let delta_y = presenter.get_offset_for_popup();
576
577        // The extra 5 px I think is related to default popup placement behavior
578        popup.set_vertical_offset(delta_y + 5.0);
579    }
580
581    /// Clears the selected date.
582    pub fn clear(&self) {
583        self.set_current_value(Self::selected_date_property(), None);
584    }
585}