Skip to main content

ferroui_themes_material/
time_picker.rs

1// Copyright 2023 The Android Open Source Project (reference behavior).
2// Copyright 2026 FerroUI contributors (Rust adaptation).
3// SPDX-License-Identifier: Apache-2.0
4//! Material clock and keyboard time selection. Reference: AndroidX Material3
5//! TimePicker.kt at e4d933c98df7baa7ea2b83eea6e086915961e0ff (1.5.0-beta02).
6//! This is the reusable picker content; applications own their dialog title/actions.
7
8use crate::motion::MaterialMotionScheme;
9use crate::time_picker_dial::MaterialTimePickerDial;
10use crate::time_picker_state::{
11    MaterialTimeDisplayMode, MaterialTimeSelection, TimePickerMetrics, display_hour, parse_input,
12};
13use ferroui_base::animation::TimeSpan;
14use ferroui_base::data::BindingMode;
15use ferroui_base::input::platform::{ClipboardErrorKind, ClipboardExtensions};
16use ferroui_base::input::text_input::{
17    TextInputContentType, TextInputOptions, TextInputReturnKeyType,
18};
19use ferroui_base::input::{
20    FocusChangedEventArgs, InputElement, InputElementImpl, InputElementImplExt, Key, KeyEventArgs,
21    KeyModifiers, KeyboardNavigationHandler, NavigationDirection, NavigationMethod,
22    TextInputEventArgs,
23};
24use ferroui_base::interactivity::{
25    InteractiveImpl, RoutedEvent, RoutedEventArgs, RoutingStrategies,
26};
27use ferroui_base::layout::{LayoutableImpl, Orientation};
28use ferroui_base::media::FlowDirection;
29use ferroui_base::reactive::IDisposable;
30use ferroui_base::threading::Dispatcher;
31use ferroui_base::{
32    DirectProperty, FerroObjectImpl, FerroObjectImplExt, FerroProperty,
33    FerroPropertyChangedEventArgs, Rect, Ref, Size, StyledElementImpl, StyledProperty,
34    StyledPropertyOptions, Visual, VisualImpl, VisualImplExt, VisualTreeAttachmentEventArgs,
35    WeakRef, ferro_class, ferro_class_info, ferro_impl_classes, ferro_properties,
36    ferro_routed_event, ferro_routed_event_args, instantiate,
37};
38use ferroui_controls::automation::peers::{
39    AutomationControlType, AutomationPeer, AutomationPeerImpl, ControlAutomationPeer,
40    ControlAutomationPeerImpl,
41};
42use ferroui_controls::automation::provider::{IValueProvider, ProviderError};
43use ferroui_controls::automation::{
44    AutomationLiveSetting, AutomationProperties, ElementNotEnabledException,
45};
46use ferroui_controls::primitives::{
47    TemplateAppliedEventArgs, TemplatedControl, TemplatedControlImpl, TemplatedControlImplExt,
48    ToggleButton,
49};
50use ferroui_controls::{
51    ControlImpl, Panel, PanelImpl, PastingFromClipboardEventArgs, RadioButton, TextBlock, TextBox,
52    TextBoxImpl, TextBoxImplExt,
53};
54use std::cell::{Cell, RefCell};
55use std::rc::Rc;
56use std::sync::atomic::{AtomicU64, Ordering};
57
58static GROUP_ID: AtomicU64 = AtomicU64::new(1);
59
60#[derive(Clone, Default)]
61pub struct MaterialTimeChangedEventArgs {
62    base: RoutedEventArgs,
63    old_time: TimeSpan,
64    new_time: TimeSpan,
65}
66ferro_routed_event_args!(MaterialTimeChangedEventArgs: RoutedEventArgs);
67impl MaterialTimeChangedEventArgs {
68    pub fn old_time(&self) -> TimeSpan {
69        self.old_time
70    }
71    pub fn new_time(&self) -> TimeSpan {
72        self.new_time
73    }
74}
75
76#[derive(Clone, Default)]
77struct Parts {
78    layout: Option<Ref<MaterialTimePickerLayout>>,
79    dial: Option<Ref<MaterialTimePickerDial>>,
80    hour: Option<Ref<RadioButton>>,
81    minute: Option<Ref<RadioButton>>,
82    am: Option<Ref<RadioButton>>,
83    pm: Option<Ref<RadioButton>>,
84    hour_field: Option<Ref<MaterialTimePickerField>>,
85    minute_field: Option<Ref<MaterialTimePickerField>>,
86    hour_label: Option<Ref<TextBlock>>,
87    minute_label: Option<Ref<TextBlock>>,
88    separator: Option<Ref<TextBlock>>,
89}
90
91/// A persistent, minute-precision time editor with an analog dial and numeric input.
92/// Hour is always 0–23, even when the display uses AM/PM. Invalid intermediate text
93/// is retained separately; SelectedTime and TimeChanged expose only valid values.
94#[repr(C)]
95pub struct MaterialTimePicker {
96    base: TemplatedControl,
97    parts: RefCell<Parts>,
98    subscriptions: RefCell<Vec<Rc<dyn IDisposable>>>,
99    synchronizing: Cell<bool>,
100    editing: Cell<bool>,
101    selected_time: Cell<TimeSpan>,
102    hour_valid: Cell<bool>,
103    minute_valid: Cell<bool>,
104    input_valid: Cell<bool>,
105    hour_draft: RefCell<String>,
106    minute_draft: RefCell<String>,
107    group_id: u64,
108}
109ferro_class!(MaterialTimePicker: TemplatedControl);
110ferro_class_info!(MaterialTimePicker {
111    new: MaterialTimePicker::new
112});
113ferro_impl_classes!(MaterialTimePicker: StyledElementImpl, InteractiveImpl, InputElementImpl, LayoutableImpl);
114impl ControlImpl for MaterialTimePicker {
115    fn on_create_automation_peer(this: &Self) -> Ref<AutomationPeer> {
116        MaterialTimePickerAutomationPeer::new(this).upcast()
117    }
118}
119impl FerroObjectImpl for MaterialTimePicker {
120    fn constructed(this: &Self) {
121        Self::parent_constructed(this);
122        this.synchronize(true);
123    }
124    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
125        Self::parent_on_property_changed(this, change);
126        if this.synchronizing.get() {
127            return;
128        }
129        let p = change.property();
130        let value_changed =
131            p == Self::hour_property().as_property() || p == Self::minute_property().as_property();
132        let reset_draft = value_changed || p == Self::is_24_hour_property().as_property();
133        if value_changed
134            || p == Self::is_24_hour_property().as_property()
135            || p == Self::selection_property().as_property()
136            || p == Self::display_mode_property().as_property()
137            || p == Self::orientation_property().as_property()
138            || p == Self::is_vibrant_property().as_property()
139            || p == Self::is_read_only_property().as_property()
140            || p == Self::auto_advance_property().as_property()
141            || p == Self::reduced_motion_property().as_property()
142            || p == Self::motion_scheme_property().as_property()
143            || p == Self::hour_label_property().as_property()
144            || p == Self::minute_label_property().as_property()
145            || p == Self::am_label_property().as_property()
146            || p == Self::pm_label_property().as_property()
147            || p == Self::hour_error_text_property().as_property()
148            || p == Self::minute_error_text_property().as_property()
149            || p == Visual::flow_direction_property().as_property()
150            || p == InputElement::is_effectively_enabled_property().as_property()
151        {
152            this.synchronize(reset_draft && !this.editing.get());
153        }
154        if value_changed {
155            this.publish_time();
156        }
157        if (p == Self::display_mode_property().as_property()
158            || p == Self::selection_property().as_property())
159            && this.display_mode() == MaterialTimeDisplayMode::Input
160            && this.is_attached_to_visual_tree()
161            && this.can_edit()
162        {
163            this.focus_field(this.selection());
164        }
165    }
166}
167impl VisualImpl for MaterialTimePicker {
168    fn on_attached_to_visual_tree(this: &Self, e: &VisualTreeAttachmentEventArgs) {
169        Self::parent_on_attached_to_visual_tree(this, e);
170        this.assign_radio_groups();
171        this.synchronize(false);
172    }
173    fn on_detached_from_visual_tree(this: &Self, e: &VisualTreeAttachmentEventArgs) {
174        if let Some(dial) = this.parts.borrow().dial.clone() {
175            dial.cancel_interaction();
176        }
177        Self::parent_on_detached_from_visual_tree(this, e);
178    }
179}
180impl TemplatedControlImpl for MaterialTimePicker {
181    fn on_apply_template(this: &Self, e: &TemplateAppliedEventArgs) {
182        this.release_parts();
183        Self::parent_on_apply_template(this, e);
184        let scope = e.name_scope();
185        let parts = Parts {
186            layout: scope.find_as("PART_Layout"),
187            dial: scope.find_as("PART_Dial"),
188            hour: scope.find_as("PART_Hour"),
189            minute: scope.find_as("PART_Minute"),
190            am: scope.find_as("PART_Am"),
191            pm: scope.find_as("PART_Pm"),
192            hour_field: scope.find_as("PART_HourField"),
193            minute_field: scope.find_as("PART_MinuteField"),
194            hour_label: scope.find_as("PART_HourLabel"),
195            minute_label: scope.find_as("PART_MinuteLabel"),
196            separator: scope.find_as("PART_Separator"),
197        };
198        let weak = this.to_ref().downgrade();
199        if let Some(layout) = &parts.layout {
200            *layout.owner.borrow_mut() = Some(weak.clone());
201        }
202        if let Some(dial) = &parts.dial {
203            dial.set_owner(Some(weak.clone()));
204        }
205        for (button, role) in [
206            (&parts.hour, 0),
207            (&parts.minute, 1),
208            (&parts.am, 2),
209            (&parts.pm, 3),
210        ] {
211            if let Some(button) = button {
212                // Runtime identities are installed only after attachment. This keeps
213                // independently loaded, unattached markup trees deterministic.
214                if this.is_attached_to_visual_tree() {
215                    button.set_group_name(Some(format!(
216                        "MaterialTimePicker{}-{}",
217                        this.group_id,
218                        role / 2
219                    )));
220                }
221                let owner = weak.clone();
222                let weak_button = button.downgrade();
223                let subscription = button.property_changed(move |e| {
224                    if e.property() != ToggleButton::is_checked_property().as_property() {
225                        return;
226                    }
227                    if let (Some(owner), Some(button)) = (owner.upgrade(), weak_button.upgrade()) {
228                        if !owner.synchronizing.get()
229                            && button.is_checked() == Some(true)
230                            && owner.can_edit()
231                        {
232                            match role {
233                                0 => {
234                                    owner.set_selection(MaterialTimeSelection::Hour);
235                                    owner.focus_selection();
236                                }
237                                1 => {
238                                    owner.set_selection(MaterialTimeSelection::Minute);
239                                    owner.focus_selection();
240                                }
241                                2 => owner.set_period(false),
242                                _ => owner.set_period(true),
243                            }
244                        }
245                    }
246                });
247                this.subscriptions.borrow_mut().push(subscription);
248                if role < 2 {
249                    let owner = weak.clone();
250                    let subscription = button.add_disposable_handler_with(
251                        ferroui_controls::Button::click_event(),
252                        move |_, _| {
253                            if let Some(owner) = owner.upgrade().filter(|o| o.can_edit()) {
254                                owner.focus_selection();
255                            }
256                        },
257                        RoutingStrategies::BUBBLE,
258                        false,
259                    );
260                    this.subscriptions.borrow_mut().push(subscription);
261                }
262            }
263        }
264        for (field, selection) in [
265            (&parts.hour_field, MaterialTimeSelection::Hour),
266            (&parts.minute_field, MaterialTimeSelection::Minute),
267        ] {
268            if let Some(field) = field {
269                *field.owner.borrow_mut() = Some(weak.clone());
270                field.selection.set(selection);
271                TextInputOptions::set_content_type(field, TextInputContentType::Digits);
272                TextInputOptions::set_return_key_type(
273                    field,
274                    if selection == MaterialTimeSelection::Hour {
275                        TextInputReturnKeyType::Next
276                    } else {
277                        TextInputReturnKeyType::Done
278                    },
279                );
280            }
281        }
282        for label in [&parts.hour_label, &parts.minute_label]
283            .into_iter()
284            .flatten()
285        {
286            AutomationProperties::set_live_setting(label, AutomationLiveSetting::Polite);
287        }
288        *this.parts.borrow_mut() = parts;
289        this.synchronize(false);
290        if this.display_mode() == MaterialTimeDisplayMode::Input
291            && this.is_attached_to_visual_tree()
292            && this.can_edit()
293        {
294            this.focus_field(this.selection());
295        }
296    }
297}
298
299ferro_properties! { impl MaterialTimePicker {
300    pub fn hour_property() -> StyledProperty<i32> { FerroProperty::register_with::<Self,_>("Hour", StyledPropertyOptions::new(0).validate(|v: &i32| (0..24).contains(v)).default_binding_mode(BindingMode::TwoWay)) }
301    pub fn minute_property() -> StyledProperty<i32> { FerroProperty::register_with::<Self,_>("Minute", StyledPropertyOptions::new(0).validate(|v: &i32| (0..60).contains(v)).default_binding_mode(BindingMode::TwoWay)) }
302    pub fn is_24_hour_property() -> StyledProperty<bool> { FerroProperty::register::<Self,_>("Is24Hour", false) }
303    pub fn selection_property() -> StyledProperty<MaterialTimeSelection> { FerroProperty::register::<Self,_>("Selection", MaterialTimeSelection::Hour) }
304    pub fn display_mode_property() -> StyledProperty<MaterialTimeDisplayMode> { FerroProperty::register::<Self,_>("DisplayMode", MaterialTimeDisplayMode::Dial) }
305    pub fn orientation_property() -> StyledProperty<Orientation> { FerroProperty::register::<Self,_>("Orientation", Orientation::Vertical) }
306    pub fn is_vibrant_property() -> StyledProperty<bool> { FerroProperty::register::<Self,_>("IsVibrant", false) }
307    pub fn is_read_only_property() -> StyledProperty<bool> { FerroProperty::register::<Self,_>("IsReadOnly", false) }
308    pub fn auto_advance_property() -> StyledProperty<bool> { FerroProperty::register::<Self,_>("AutoAdvance", true) }
309    pub fn reduced_motion_property() -> StyledProperty<bool> { FerroProperty::register::<Self,_>("ReducedMotion", false) }
310    pub fn motion_scheme_property() -> StyledProperty<MaterialMotionScheme> { FerroProperty::register::<Self,_>("MotionScheme", MaterialMotionScheme::Expressive) }
311    pub fn hour_label_property() -> StyledProperty<String> { FerroProperty::register::<Self,_>("HourLabel", "Hour".to_owned()) }
312    pub fn minute_label_property() -> StyledProperty<String> { FerroProperty::register::<Self,_>("MinuteLabel", "Minute".to_owned()) }
313    pub fn am_label_property() -> StyledProperty<String> { FerroProperty::register::<Self,_>("AmLabel", "AM".to_owned()) }
314    pub fn pm_label_property() -> StyledProperty<String> { FerroProperty::register::<Self,_>("PmLabel", "PM".to_owned()) }
315    pub fn hour_error_text_property() -> StyledProperty<String> { FerroProperty::register::<Self,_>("HourErrorText", "Enter a valid hour".to_owned()) }
316    pub fn minute_error_text_property() -> StyledProperty<String> { FerroProperty::register::<Self,_>("MinuteErrorText", "Enter a minute from 0 to 59".to_owned()) }
317    pub fn selected_time_property() -> DirectProperty<MaterialTimePicker, TimeSpan> { FerroProperty::register_direct::<Self,_>("SelectedTime", |o| o.selected_time(), Some(|o,v| o.set_selected_time(v)), TimeSpan::ZERO) }
318    pub fn is_hour_input_valid_property() -> DirectProperty<MaterialTimePicker,bool> { FerroProperty::register_direct::<Self,_>("IsHourInputValid", |o| o.is_hour_input_valid(), None, true) }
319    pub fn is_minute_input_valid_property() -> DirectProperty<MaterialTimePicker,bool> { FerroProperty::register_direct::<Self,_>("IsMinuteInputValid", |o| o.is_minute_input_valid(), None, true) }
320    pub fn is_input_valid_property() -> DirectProperty<MaterialTimePicker,bool> { FerroProperty::register_direct::<Self,_>("IsInputValid", |o| o.is_input_valid(), None, true) }
321}}
322
323macro_rules! accessors {
324    ($get:ident, $set:ident, $property:ident, $ty:ty) => {
325        pub fn $get(&self) -> $ty {
326            self.get_value(Self::$property())
327        }
328        pub fn $set(&self, value: $ty) {
329            self.set_value(Self::$property(), value);
330        }
331    };
332}
333impl MaterialTimePicker {
334    ferro_routed_event!(
335        pub fn time_changed_event() -> RoutedEvent<MaterialTimeChangedEventArgs> {
336            RoutedEvent::register::<Self, _>("TimeChanged", RoutingStrategies::BUBBLE)
337        }
338    );
339    ferro_routed_event!(
340        pub fn time_change_finished_event() -> RoutedEvent<RoutedEventArgs> {
341            RoutedEvent::register::<Self, _>("TimeChangeFinished", RoutingStrategies::BUBBLE)
342        }
343    );
344    pub fn construct() -> Self {
345        Self {
346            base: TemplatedControl::construct(),
347            parts: RefCell::new(Parts::default()),
348            subscriptions: RefCell::new(Vec::new()),
349            synchronizing: Cell::new(false),
350            editing: Cell::new(false),
351            selected_time: Cell::new(TimeSpan::ZERO),
352            hour_valid: Cell::new(true),
353            minute_valid: Cell::new(true),
354            input_valid: Cell::new(true),
355            hour_draft: RefCell::new("12".into()),
356            minute_draft: RefCell::new("00".into()),
357            group_id: GROUP_ID.fetch_add(1, Ordering::Relaxed),
358        }
359    }
360    pub fn new() -> Ref<Self> {
361        instantiate(Self::construct())
362    }
363    accessors!(hour, set_hour, hour_property, i32);
364    accessors!(minute, set_minute, minute_property, i32);
365    accessors!(is_24_hour, set_is_24_hour, is_24_hour_property, bool);
366    accessors!(
367        selection,
368        set_selection,
369        selection_property,
370        MaterialTimeSelection
371    );
372    accessors!(
373        display_mode,
374        set_display_mode,
375        display_mode_property,
376        MaterialTimeDisplayMode
377    );
378    accessors!(
379        orientation,
380        set_orientation,
381        orientation_property,
382        Orientation
383    );
384    accessors!(is_vibrant, set_is_vibrant, is_vibrant_property, bool);
385    accessors!(is_read_only, set_is_read_only, is_read_only_property, bool);
386    /// Whether pointer and numeric input can advance automatically to minutes.
387    /// Hosts with touch exploration enabled should set this to false. Clock keyboard
388    /// activation and accessibility selection never auto-advance.
389    pub fn auto_advance(&self) -> bool {
390        self.get_value(Self::auto_advance_property())
391    }
392    pub fn set_auto_advance(&self, value: bool) {
393        self.set_value(Self::auto_advance_property(), value);
394    }
395    accessors!(
396        reduced_motion,
397        set_reduced_motion,
398        reduced_motion_property,
399        bool
400    );
401    accessors!(
402        motion_scheme,
403        set_motion_scheme,
404        motion_scheme_property,
405        MaterialMotionScheme
406    );
407    accessors!(hour_label, set_hour_label, hour_label_property, String);
408    accessors!(
409        minute_label,
410        set_minute_label,
411        minute_label_property,
412        String
413    );
414    accessors!(am_label, set_am_label, am_label_property, String);
415    accessors!(pm_label, set_pm_label, pm_label_property, String);
416    accessors!(
417        hour_error_text,
418        set_hour_error_text,
419        hour_error_text_property,
420        String
421    );
422    accessors!(
423        minute_error_text,
424        set_minute_error_text,
425        minute_error_text_property,
426        String
427    );
428    pub fn selected_time(&self) -> TimeSpan {
429        self.selected_time.get()
430    }
431    pub fn is_hour_input_valid(&self) -> bool {
432        self.hour_valid.get()
433    }
434    pub fn is_minute_input_valid(&self) -> bool {
435        self.minute_valid.get()
436    }
437    pub fn is_input_valid(&self) -> bool {
438        self.input_valid.get()
439    }
440    pub fn hour_input(&self) -> String {
441        self.hour_draft.borrow().clone()
442    }
443    pub fn minute_input(&self) -> String {
444        self.minute_draft.borrow().clone()
445    }
446    /// Sets a valid 24-hour time atomically, emitting at most one TimeChanged event.
447    pub fn set_time(&self, hour: i32, minute: i32) {
448        assert!(
449            (0..24).contains(&hour) && (0..60).contains(&minute),
450            "time must be within 00:00..23:59"
451        );
452        self.synchronizing.set(true);
453        self.set_current_value(Self::hour_property(), hour);
454        self.set_current_value(Self::minute_property(), minute);
455        self.synchronizing.set(false);
456        self.synchronize(!self.editing.get());
457        self.publish_time();
458    }
459    pub fn set_selected_time(&self, value: TimeSpan) {
460        let minutes = value.total_minutes();
461        assert!(
462            minutes.is_finite() && (0.0..1440.0).contains(&minutes),
463            "time must be within one day"
464        );
465        let minutes = minutes.floor() as i32;
466        self.set_time(minutes / 60, minutes % 60);
467    }
468    pub(crate) fn can_edit(&self) -> bool {
469        self.is_effectively_enabled() && !self.is_read_only()
470    }
471    pub(crate) fn set_period(&self, pm: bool) {
472        if self.can_edit() && self.is_hour_input_valid() && (self.hour() >= 12) != pm {
473            self.set_time(self.hour() % 12 + if pm { 12 } else { 0 }, self.minute());
474            self.finish_edit();
475        }
476    }
477    pub(crate) fn select_number(&self, value: i32) {
478        if !self.can_edit() {
479            return;
480        }
481        if self.selection() == MaterialTimeSelection::Minute {
482            self.set_time(self.hour(), value);
483        } else {
484            self.set_time(
485                if self.is_24_hour() {
486                    value
487                } else {
488                    value % 12 + if self.hour() >= 12 { 12 } else { 0 }
489                },
490                self.minute(),
491            );
492        }
493    }
494    pub(crate) fn finish_edit(&self) {
495        if self.is_input_valid() {
496            self.raise_event(&RoutedEventArgs::with_event(
497                Self::time_change_finished_event(),
498            ));
499        }
500    }
501    fn publish_time(&self) {
502        let time = TimeSpan::from_minutes((self.hour() * 60 + self.minute()) as f64);
503        let old = self.selected_time();
504        if old == time {
505            return;
506        }
507        self.set_and_raise_cell(Self::selected_time_property(), &self.selected_time, time);
508        let e = MaterialTimeChangedEventArgs {
509            base: RoutedEventArgs::with_event(Self::time_changed_event()),
510            old_time: old,
511            new_time: time,
512        };
513        self.raise_event(&e);
514    }
515    fn release_parts(&self) {
516        for subscription in self.subscriptions.borrow_mut().drain(..) {
517            subscription.dispose();
518        }
519        let old = self.parts.replace(Parts::default());
520        if let Some(layout) = old.layout {
521            layout.owner.borrow_mut().take();
522        }
523        if let Some(dial) = old.dial {
524            dial.cancel_interaction();
525            dial.set_owner(None);
526        }
527        for field in [old.hour_field, old.minute_field].into_iter().flatten() {
528            field.owner.borrow_mut().take();
529        }
530    }
531    fn assign_radio_groups(&self) {
532        let parts = self.parts.borrow().clone();
533        let was_syncing = self.synchronizing.replace(true);
534        for (button, group) in [
535            (&parts.hour, 0),
536            (&parts.minute, 0),
537            (&parts.am, 1),
538            (&parts.pm, 1),
539        ] {
540            if let Some(button) = button {
541                button.set_group_name(Some(format!("MaterialTimePicker{}-{group}", self.group_id)));
542            }
543        }
544        self.synchronizing.set(was_syncing);
545    }
546    fn synchronize(&self, reset_drafts: bool) {
547        if self.synchronizing.replace(true) {
548            return;
549        }
550        if reset_drafts {
551            *self.hour_draft.borrow_mut() =
552                format!("{:02}", display_hour(self.hour(), self.is_24_hour()));
553            *self.minute_draft.borrow_mut() = format!("{:02}", self.minute());
554            self.set_validity(true, true);
555        }
556        let parts = self.parts.borrow().clone();
557        let input = self.display_mode() == MaterialTimeDisplayMode::Input;
558        self.pseudo_classes().set(":input", input);
559        self.pseudo_classes().set(":vibrant", self.is_vibrant());
560        self.pseudo_classes()
561            .set(":invalid", !self.is_input_valid());
562        for (button, selected, text, label) in [
563            (
564                &parts.hour,
565                self.selection() == MaterialTimeSelection::Hour,
566                if self.is_hour_input_valid() {
567                    format!("{:02}", display_hour(self.hour(), self.is_24_hour()))
568                } else {
569                    self.hour_input()
570                },
571                self.hour_label(),
572            ),
573            (
574                &parts.minute,
575                self.selection() == MaterialTimeSelection::Minute,
576                if self.is_minute_input_valid() {
577                    format!("{:02}", self.minute())
578                } else {
579                    self.minute_input()
580                },
581                self.minute_label(),
582            ),
583            (
584                &parts.am,
585                self.hour() < 12,
586                self.am_label(),
587                self.am_label(),
588            ),
589            (
590                &parts.pm,
591                self.hour() >= 12,
592                self.pm_label(),
593                self.pm_label(),
594            ),
595        ] {
596            if let Some(button) = button {
597                button.set_content(Some(Rc::new(text)));
598                button.set_is_checked(Some(selected));
599                button.set_is_enabled(self.can_edit());
600                button.pseudo_classes().set(":vibrant", self.is_vibrant());
601                button.pseudo_classes().set(":input", input);
602                button.pseudo_classes().set(
603                    ":horizontal",
604                    !input && self.orientation() == Orientation::Horizontal,
605                );
606                AutomationProperties::set_name(button, Some(&label));
607            }
608        }
609        for (button, selection, valid) in [
610            (
611                &parts.hour,
612                MaterialTimeSelection::Hour,
613                self.is_hour_input_valid(),
614            ),
615            (
616                &parts.minute,
617                MaterialTimeSelection::Minute,
618                self.is_minute_input_valid(),
619            ),
620        ] {
621            if let Some(button) = button {
622                button.set_is_visible(!input || self.selection() != selection);
623                button.pseudo_classes().set(":invalid", !valid);
624            }
625        }
626        for button in [&parts.am, &parts.pm].into_iter().flatten() {
627            button.set_is_visible(!self.is_24_hour());
628            button.set_is_enabled(self.can_edit() && self.is_hour_input_valid());
629        }
630        for (field, draft, valid, label, error) in [
631            (
632                &parts.hour_field,
633                self.hour_input(),
634                self.is_hour_input_valid(),
635                self.hour_label(),
636                self.hour_error_text(),
637            ),
638            (
639                &parts.minute_field,
640                self.minute_input(),
641                self.is_minute_input_valid(),
642                self.minute_label(),
643                self.minute_error_text(),
644            ),
645        ] {
646            if let Some(field) = field {
647                field.set_is_visible(input && self.selection() == field.selection.get());
648                field.set_is_read_only(self.is_read_only());
649                field.pseudo_classes().set(":vibrant", self.is_vibrant());
650                field.pseudo_classes().set(":invalid", !valid);
651                if field.text().as_deref() != Some(&draft) {
652                    field.set_text(Some(&draft));
653                }
654                AutomationProperties::set_name(field, Some(&label));
655                AutomationProperties::set_help_text(
656                    field,
657                    Some(if valid { &label } else { &error }),
658                );
659            }
660        }
661        for (label, valid, text, error) in [
662            (
663                &parts.hour_label,
664                self.is_hour_input_valid(),
665                self.hour_label(),
666                self.hour_error_text(),
667            ),
668            (
669                &parts.minute_label,
670                self.is_minute_input_valid(),
671                self.minute_label(),
672                self.minute_error_text(),
673            ),
674        ] {
675            if let Some(label) = label {
676                label.set_is_visible(input && (!self.is_vibrant() || !valid));
677                label.set_text(Some(if valid { &text } else { &error }));
678                label.pseudo_classes().set(":invalid", !valid);
679            }
680        }
681        if let Some(dial) = &parts.dial {
682            dial.set_is_visible(!input);
683            dial.set_is_enabled(self.can_edit());
684            dial.synchronize();
685        }
686        if let Some(layout) = &parts.layout {
687            layout.invalidate_measure();
688        }
689        self.synchronizing.set(false);
690    }
691    fn set_validity(&self, hour: bool, minute: bool) {
692        self.set_and_raise_cell(Self::is_hour_input_valid_property(), &self.hour_valid, hour);
693        self.set_and_raise_cell(
694            Self::is_minute_input_valid_property(),
695            &self.minute_valid,
696            minute,
697        );
698        self.set_and_raise_cell(
699            Self::is_input_valid_property(),
700            &self.input_valid,
701            hour && minute,
702        );
703    }
704    /// Edits an input field without discarding an invalid numeric draft. Returns false
705    /// for rejected syntax; such a replacement leaves both text and value unchanged.
706    pub fn set_input(&self, selection: MaterialTimeSelection, text: &str) -> bool {
707        if !self.can_edit() {
708            return false;
709        }
710        let Ok(value) = parse_input(text, selection, self.is_24_hour(), self.hour() >= 12) else {
711            return false;
712        };
713        self.editing.set(true);
714        match selection {
715            MaterialTimeSelection::Hour => {
716                *self.hour_draft.borrow_mut() = text.into();
717                self.set_validity(value.is_some(), self.is_minute_input_valid());
718            }
719            MaterialTimeSelection::Minute => {
720                *self.minute_draft.borrow_mut() = text.into();
721                self.set_validity(self.is_hour_input_valid(), value.is_some());
722            }
723        }
724        if let Some(value) = value {
725            if selection == MaterialTimeSelection::Hour {
726                self.set_time(value, self.minute());
727            } else {
728                self.set_time(self.hour(), value);
729            }
730        } else {
731            self.synchronize(false);
732        }
733        self.editing.set(false);
734        true
735    }
736    pub(crate) fn focus_field(&self, selection: MaterialTimeSelection) {
737        self.set_selection(selection);
738        let parts = self.parts.borrow().clone();
739        let field = if selection == MaterialTimeSelection::Hour {
740            parts.hour_field
741        } else {
742            parts.minute_field
743        };
744        if let Some(field) = field {
745            field.focus();
746            field.select_all();
747        }
748    }
749    pub(crate) fn focus_dial(&self) {
750        if let Some(dial) = self.parts.borrow().dial.clone() {
751            dial.focus_selected();
752        }
753    }
754    fn focus_selection(&self) {
755        if self.display_mode() == MaterialTimeDisplayMode::Input {
756            self.focus_field(self.selection());
757        } else {
758            self.focus_dial();
759        }
760    }
761    pub(crate) fn exit_dial(&self, backwards: bool) {
762        let parts = self.parts.borrow().clone();
763        let target = if backwards {
764            if self.selection() == MaterialTimeSelection::Hour {
765                parts.hour.clone()
766            } else {
767                parts.minute.clone()
768            }
769        } else if self.selection() == MaterialTimeSelection::Hour {
770            parts.minute.clone()
771        } else if !self.is_24_hour() {
772            parts.am
773        } else {
774            None
775        };
776        if let Some(target) = target {
777            target.focus_with(NavigationMethod::Tab, KeyModifiers::empty());
778        } else if let Some(minute) = parts.minute {
779            if let Some(next) =
780                KeyboardNavigationHandler::get_next(&minute.upcast(), NavigationDirection::Next)
781            {
782                next.focus_with(NavigationMethod::Tab, KeyModifiers::empty());
783            }
784        }
785    }
786}
787impl Drop for MaterialTimePicker {
788    fn drop(&mut self) {
789        for subscription in self.subscriptions.get_mut().drain(..) {
790            subscription.dispose();
791        }
792    }
793}
794
795/// TextBox subclass keeps the platform editor, IME, clipboard, selection, and value provider.
796#[repr(C)]
797pub struct MaterialTimePickerField {
798    base: TextBox,
799    owner: RefCell<Option<WeakRef<MaterialTimePicker>>>,
800    selection: Cell<MaterialTimeSelection>,
801}
802ferro_class!(MaterialTimePickerField: TextBox);
803ferro_class_info!(MaterialTimePickerField {
804    new: MaterialTimePickerField::new
805});
806ferro_impl_classes!(MaterialTimePickerField: StyledElementImpl, VisualImpl, LayoutableImpl, InteractiveImpl, ControlImpl, TemplatedControlImpl);
807impl FerroObjectImpl for MaterialTimePickerField {
808    fn constructed(this: &Self) {
809        Self::parent_constructed(this);
810        let weak = this.to_ref().downgrade();
811        this.pasting_from_clipboard(move |_, event| {
812            let Some(args) = event.downcast_ref::<PastingFromClipboardEventArgs>() else {
813                return;
814            };
815            let Some(clipboard) = args.clipboard() else {
816                return;
817            };
818            event.set_handled(true);
819            let weak = weak.clone();
820            // Paste uses the same input transformation as typing and IME commits. The
821            // ordinary TextBox paste path bypasses on_text_input and would update its
822            // presenter/caret after a rejected Text property replacement.
823            drop(
824                Dispatcher::ui_thread()
825                    .to_task_scheduler()
826                    .start_local(async move {
827                        match clipboard.try_get_text_async().await {
828                            Ok(Some(text)) => {
829                                if let Some(field) =
830                                    weak.upgrade().filter(|f| f.is_attached_to_visual_tree())
831                                {
832                                    let mut input = TextInputEventArgs::new();
833                                    input.text = Some(text);
834                                    input.set_routed_event(Some(InputElement::text_input_event()));
835                                    field.raise_event(&input);
836                                }
837                            }
838                            Ok(None) => {}
839                            Err(error) => {
840                                if !matches!(
841                                    error.kind(),
842                                    ClipboardErrorKind::Timeout
843                                        | ClipboardErrorKind::Canceled
844                                        | ClipboardErrorKind::AccessDenied
845                                        | ClipboardErrorKind::Platform
846                                ) {
847                                    panic!("Unhandled time-input clipboard error: {error}");
848                                }
849                            }
850                        }
851                    }),
852            );
853        });
854    }
855    fn on_property_changed(this: &Self, e: &FerroPropertyChangedEventArgs<'_>) {
856        Self::parent_on_property_changed(this, e);
857        if e.property() == TextBox::text_property().as_property() {
858            if let Some(owner) = this.owner() {
859                if !owner.synchronizing.get() {
860                    if !owner.set_input(this.selection.get(), this.text().as_deref().unwrap_or(""))
861                    {
862                        owner.synchronize(false);
863                    }
864                }
865            }
866        }
867    }
868}
869impl TextBoxImpl for MaterialTimePickerField {
870    fn coerce_text(this: &Self, text: Option<String>) -> Option<String> {
871        if let Some(owner) = this.owner() {
872            if !owner.synchronizing.get()
873                && parse_input(
874                    text.as_deref().unwrap_or(""),
875                    this.selection.get(),
876                    owner.is_24_hour(),
877                    owner.hour() >= 12,
878                )
879                .is_err()
880            {
881                return this.text();
882            }
883        }
884        Self::parent_coerce_text(this, text)
885    }
886}
887impl InputElementImpl for MaterialTimePickerField {
888    fn on_got_focus(this: &Self, e: &FocusChangedEventArgs) {
889        Self::parent_on_got_focus(this, e);
890        if let Some(owner) = this.owner() {
891            owner.set_selection(this.selection.get());
892        }
893    }
894    fn on_text_input(this: &Self, e: &TextInputEventArgs) {
895        let Some(owner) = this.owner() else {
896            Self::parent_on_text_input(this, e);
897            return;
898        };
899        if !owner.can_edit() {
900            e.set_handled(true);
901            return;
902        }
903        let text = e.text.as_deref().unwrap_or("");
904        if !text.bytes().all(|c| c.is_ascii_digit()) {
905            e.set_handled(true);
906            return;
907        }
908        let current = this.text().unwrap_or_default();
909        if text.len() == 1 && current.len() == 2 && this.selection_start() == this.selection_end() {
910            this.select_all();
911        } else {
912            let retained = current.len().saturating_sub(
913                (this.selection_start() - this.selection_end()).unsigned_abs() as usize,
914            );
915            if retained + text.len() > 2 {
916                e.set_handled(true);
917                return;
918            }
919        }
920        Self::parent_on_text_input(this, e);
921        let value = this.text().unwrap_or_default();
922        if this.selection.get() == MaterialTimeSelection::Hour
923            && owner.auto_advance()
924            && owner.is_hour_input_valid()
925            && (value.len() == 2
926                || (!owner.is_24_hour()
927                    && value.parse::<i32>().is_ok_and(|v| (2..=9).contains(&v))))
928        {
929            owner.focus_field(MaterialTimeSelection::Minute);
930        }
931    }
932    fn on_key_down(this: &Self, e: &KeyEventArgs) {
933        if e.key == Key::Enter {
934            if let Some(owner) = this.owner() {
935                if owner.is_input_valid() {
936                    if this.selection.get() == MaterialTimeSelection::Hour {
937                        owner.focus_field(MaterialTimeSelection::Minute);
938                    } else {
939                        owner.finish_edit();
940                    }
941                }
942                e.set_handled(true);
943                return;
944            }
945        }
946        Self::parent_on_key_down(this, e);
947    }
948}
949impl MaterialTimePickerField {
950    pub fn construct() -> Self {
951        Self {
952            base: TextBox::construct(),
953            owner: RefCell::new(None),
954            selection: Cell::new(MaterialTimeSelection::Hour),
955        }
956    }
957    pub fn new() -> Ref<Self> {
958        instantiate(Self::construct())
959    }
960    fn owner(&self) -> Option<Ref<MaterialTimePicker>> {
961        self.owner.borrow().as_ref().and_then(WeakRef::upgrade)
962    }
963}
964
965/// Native measured layout, including compact landscape dial sizing and RTL outer layout.
966#[repr(C)]
967pub struct MaterialTimePickerLayout {
968    base: Panel,
969    owner: RefCell<Option<WeakRef<MaterialTimePicker>>>,
970    metrics: Cell<TimePickerMetrics>,
971}
972ferro_class!(MaterialTimePickerLayout: Panel);
973ferro_class_info!(MaterialTimePickerLayout {
974    new: MaterialTimePickerLayout::new
975});
976ferro_impl_classes!(MaterialTimePickerLayout: FerroObjectImpl, StyledElementImpl, InputElementImpl, InteractiveImpl, ControlImpl, PanelImpl);
977impl VisualImpl for MaterialTimePickerLayout {
978    fn bypass_flow_direction_policies(_: &Self) -> bool {
979        true
980    }
981}
982impl MaterialTimePickerLayout {
983    pub fn construct() -> Self {
984        Self {
985            base: Panel::construct(),
986            owner: RefCell::new(None),
987            metrics: Cell::new(TimePickerMetrics::new(
988                false,
989                false,
990                MaterialTimeDisplayMode::Dial,
991                Orientation::Vertical,
992                f64::INFINITY,
993            )),
994        }
995    }
996    pub fn new() -> Ref<Self> {
997        instantiate(Self::construct())
998    }
999    fn owner(&self) -> Option<Ref<MaterialTimePicker>> {
1000        self.owner.borrow().as_ref().and_then(WeakRef::upgrade)
1001    }
1002}
1003impl LayoutableImpl for MaterialTimePickerLayout {
1004    fn measure_override(this: &Self, available: Size) -> Size {
1005        let Some(owner) = this.owner() else {
1006            return Size::default();
1007        };
1008        let mut metrics = TimePickerMetrics::new(
1009            owner.is_vibrant(),
1010            owner.is_24_hour(),
1011            owner.display_mode(),
1012            owner.orientation(),
1013            available.height,
1014        );
1015        if metrics.input && !owner.is_input_valid() {
1016            metrics.support_height = 39.0;
1017        }
1018        this.metrics.set(metrics);
1019        let parts = owner.parts.borrow().clone();
1020        for field in [parts.hour_field, parts.minute_field].into_iter().flatten() {
1021            field.measure(Size::new(metrics.field_width, metrics.field_height));
1022        }
1023        for button in [parts.hour, parts.minute].into_iter().flatten() {
1024            button.measure(Size::new(metrics.field_width, metrics.field_height));
1025        }
1026        for label in [parts.hour_label, parts.minute_label].into_iter().flatten() {
1027            label.measure(Size::new(metrics.field_width, metrics.support_height));
1028        }
1029        if let Some(separator) = parts.separator {
1030            separator.measure(Size::new(metrics.separator, metrics.field_height));
1031        }
1032        for button in [parts.am, parts.pm].into_iter().flatten() {
1033            button.measure(if metrics.horizontal {
1034                Size::new(
1035                    (metrics.fields_width() - metrics.period_inner_gap) / 2.0,
1036                    if owner.is_vibrant() { 40.0 } else { 38.0 },
1037                )
1038            } else {
1039                Size::new(
1040                    metrics.period_width,
1041                    (metrics.field_height - metrics.period_inner_gap) / 2.0,
1042                )
1043            });
1044        }
1045        if let Some(dial) = parts.dial {
1046            dial.measure(Size::new(metrics.dial_size, metrics.dial_size));
1047        }
1048        metrics.desired_size(owner.is_24_hour(), owner.is_vibrant())
1049    }
1050    fn arrange_override(this: &Self, size: Size) -> Size {
1051        let Some(owner) = this.owner() else {
1052            return size;
1053        };
1054        let m = this.metrics.get();
1055        let parts = owner.parts.borrow().clone();
1056        let display = m.display_size(owner.is_24_hour(), owner.is_vibrant());
1057        let desired = m.desired_size(owner.is_24_hour(), owner.is_vibrant());
1058        let rtl = owner.flow_direction() == FlowDirection::RightToLeft;
1059        let ox = (size.width - desired.width).max(0.0) / 2.0;
1060        let oy = (size.height - desired.height).max(0.0) / 2.0;
1061        let dx = if m.horizontal {
1062            if rtl {
1063                ox + m.dial_size + m.dial_gap
1064            } else {
1065                ox
1066            }
1067        } else {
1068            ox + (desired.width - display.width) / 2.0
1069        };
1070        let dy = if m.horizontal {
1071            oy + (desired.height - display.height) / 2.0
1072        } else {
1073            oy
1074        };
1075        let fx = dx
1076            + if rtl && !m.horizontal && !owner.is_24_hour() {
1077                m.period_width + m.period_gap
1078            } else {
1079                0.0
1080            };
1081        let hour = Rect::new(fx, dy, m.field_width, m.field_height);
1082        let minute = Rect::new(
1083            fx + m.field_width + m.separator,
1084            dy,
1085            m.field_width,
1086            m.field_height,
1087        );
1088        if let Some(c) = parts.hour {
1089            c.arrange(hour);
1090        }
1091        if let Some(c) = parts.hour_field {
1092            c.arrange(hour);
1093        }
1094        if let Some(c) = parts.minute {
1095            c.arrange(minute);
1096        }
1097        if let Some(c) = parts.minute_field {
1098            c.arrange(minute);
1099        }
1100        if let Some(c) = parts.separator {
1101            c.arrange(Rect::new(
1102                fx + m.field_width,
1103                dy - 4.0,
1104                m.separator,
1105                m.field_height,
1106            ));
1107        }
1108        if let Some(c) = parts.hour_label {
1109            c.arrange(Rect::new(
1110                hour.x,
1111                dy + m.field_height + 7.0,
1112                m.field_width,
1113                32.0,
1114            ));
1115        }
1116        if let Some(c) = parts.minute_label {
1117            c.arrange(Rect::new(
1118                minute.x,
1119                dy + m.field_height + 7.0,
1120                m.field_width,
1121                32.0,
1122            ));
1123        }
1124        for (button, index) in [(parts.am, 0), (parts.pm, 1)] {
1125            if let Some(button) = button {
1126                let rect = if m.horizontal {
1127                    let w = (m.fields_width() - m.period_inner_gap) / 2.0;
1128                    // HorizontalPeriodToggle uses absolute Placeable.place:
1129                    // AM stays left of PM even when the outer groups mirror.
1130                    Rect::new(
1131                        dx + index as f64 * (w + m.period_inner_gap),
1132                        dy + m.field_height + m.period_gap,
1133                        w,
1134                        if owner.is_vibrant() { 40.0 } else { 38.0 },
1135                    )
1136                } else {
1137                    let h = (m.field_height - m.period_inner_gap) / 2.0;
1138                    Rect::new(
1139                        if rtl {
1140                            dx
1141                        } else {
1142                            dx + m.fields_width() + m.period_gap
1143                        },
1144                        dy + index as f64 * (h + m.period_inner_gap),
1145                        m.period_width,
1146                        h,
1147                    )
1148                };
1149                button.arrange(rect);
1150            }
1151        }
1152        if let Some(dial) = parts.dial {
1153            let (x, y) = if m.horizontal {
1154                (
1155                    if rtl {
1156                        ox
1157                    } else {
1158                        ox + display.width + m.dial_gap
1159                    },
1160                    oy + (desired.height - m.dial_size) / 2.0,
1161                )
1162            } else {
1163                (
1164                    ox + (desired.width - m.dial_size) / 2.0,
1165                    oy + display.height + m.dial_gap,
1166                )
1167            };
1168            dial.arrange(Rect::new(x, y, m.dial_size, m.dial_size));
1169        }
1170        size
1171    }
1172}
1173
1174#[repr(C)]
1175pub struct MaterialTimePickerAutomationPeer {
1176    base: ControlAutomationPeer,
1177}
1178ferro_class!(MaterialTimePickerAutomationPeer: ControlAutomationPeer);
1179ferro_class_info!(MaterialTimePickerAutomationPeer { interfaces: [Rc<dyn IValueProvider> => MaterialTimePickerAutomationPeer::provider] });
1180ferro_impl_classes!(MaterialTimePickerAutomationPeer: FerroObjectImpl, ControlAutomationPeerImpl);
1181impl AutomationPeerImpl for MaterialTimePickerAutomationPeer {
1182    fn get_class_name_core(_: &Self) -> String {
1183        "MaterialTimePicker".into()
1184    }
1185    fn get_automation_control_type_core(_: &Self) -> AutomationControlType {
1186        AutomationControlType::Custom
1187    }
1188}
1189impl MaterialTimePickerAutomationPeer {
1190    pub fn new(owner: &MaterialTimePicker) -> Ref<Self> {
1191        instantiate(Self {
1192            base: ControlAutomationPeer::construct(owner),
1193        })
1194    }
1195    fn provider(peer: Ref<Self>) -> Rc<dyn IValueProvider> {
1196        Rc::new(TimeValueProvider(peer))
1197    }
1198    fn picker(&self) -> Ref<MaterialTimePicker> {
1199        ControlAutomationPeer::owner(self)
1200            .cast()
1201            .expect("time picker owner")
1202    }
1203}
1204struct TimeValueProvider(Ref<MaterialTimePickerAutomationPeer>);
1205impl IValueProvider for TimeValueProvider {
1206    fn peer(&self) -> Ref<AutomationPeer> {
1207        self.0.clone().upcast()
1208    }
1209    fn is_read_only(&self) -> bool {
1210        self.0.picker().is_read_only()
1211    }
1212    fn value(&self) -> Option<String> {
1213        let p = self.0.picker();
1214        Some(format!("{:02}:{:02}", p.hour(), p.minute()))
1215    }
1216    fn set_value(&self, value: Option<&str>) -> Result<(), ProviderError> {
1217        let owner = self.0.picker();
1218        if !owner.can_edit() {
1219            return Err(Box::new(ElementNotEnabledException::with_message(
1220                "Time picker is disabled or read-only.",
1221            )));
1222        }
1223        let time = value
1224            .and_then(|s| s.split_once(':'))
1225            .and_then(|(h, m)| Some((h.parse::<i32>().ok()?, m.parse::<i32>().ok()?)))
1226            .filter(|(h, m)| (0..24).contains(h) && (0..60).contains(m));
1227        let Some((hour, minute)) = time else {
1228            return Err(std::io::Error::new(
1229                std::io::ErrorKind::InvalidInput,
1230                "Expected a time between 00:00 and 23:59.",
1231            )
1232            .into());
1233        };
1234        owner.set_time(hour, minute);
1235        owner.finish_edit();
1236        Ok(())
1237    }
1238}