Skip to main content

tessera_components/
time_picker.rs

1//! Time picker components for selecting a clock time.
2//!
3//! ## Usage
4//!
5//! Use to let users choose a time for alarms, reminders, or schedules.
6
7use std::{f32::consts::TAU, sync::Arc};
8
9use tessera_ui::{
10    AxisConstraint, Color, ComputedData, Constraint, CursorEventContent, Dp, FocusManager,
11    FocusProperties, FocusRequester, KeyboardInput, KeyboardInputModifierNode, LayoutResult,
12    MeasurementError, Modifier, PointerInput, PointerInputModifierNode, PressKeyEventType, Px,
13    PxPosition, RenderSlot, State,
14    accesskit::{Role, Toggled},
15    layout::{MeasureScope, layout},
16    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
17    provide_context, remember, tessera,
18    time::{SystemTime, UNIX_EPOCH},
19    use_context, winit,
20};
21
22use crate::{
23    alignment::{Alignment, CrossAxisAlignment},
24    column::column,
25    icon::icon,
26    icon_button::icon_button,
27    image_vector::{ImageVectorData, TintMode},
28    material_icons::filled,
29    modifier::{InteractionState, ModifierExt as _, SemanticsArgs},
30    pipelines::image_vector::command::ImageVectorVertex,
31    res::I18N,
32    row::row,
33    shape_def::Shape,
34    spacer::spacer,
35    surface::{SurfaceStyle, surface},
36    text::text,
37    text_input::{TextInputController, text_input},
38    theme::{ContentColor, MaterialTheme},
39};
40
41const CLOCK_SIZE: Dp = Dp(256.0);
42const CLOCK_OUTER_RADIUS: f32 = 101.0;
43const CLOCK_INNER_RADIUS: f32 = 69.0;
44
45#[cfg(all(test, not(target_family = "wasm")))]
46#[path = "time_picker/accessibility_tests.rs"]
47mod accessibility_tests;
48
49/// The time unit currently selected for dial adjustment or manual entry.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum TimePickerSelection {
52    /// Adjust or enter the hour.
53    #[default]
54    Hour,
55    /// Adjust or enter the minute.
56    Minute,
57}
58
59/// Display modes supported by [`time_picker`].
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum TimePickerDisplayMode {
62    /// Touch-friendly picker layout.
63    #[default]
64    Picker,
65    /// Input-focused layout with labels.
66    Input,
67}
68
69/// Preferred arrangement of the clock dial and time controls.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum TimePickerLayout {
72    /// Stack the time controls above the clock dial.
73    #[default]
74    Vertical,
75    /// Place the time controls beside the dial when at least 524dp is
76    /// available. Narrower containers fall back to the vertical
77    /// arrangement.
78    Horizontal,
79}
80
81/// Indicates whether the selected time is in AM or PM.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum DayPeriod {
84    /// Ante meridiem (before noon).
85    Am,
86    /// Post meridiem (after noon).
87    Pm,
88}
89
90/// Holds the current selection for a time picker.
91pub struct TimePickerState {
92    hour: u8,
93    minute: u8,
94    is_24_hour: bool,
95    display_mode: TimePickerDisplayMode,
96    selection: TimePickerSelection,
97}
98
99impl TimePickerState {
100    /// Creates a time picker state with the provided initial values.
101    pub fn new(
102        initial_hour: u8,
103        initial_minute: u8,
104        is_24_hour: bool,
105        display_mode: TimePickerDisplayMode,
106    ) -> Self {
107        Self {
108            hour: clamp_hour(initial_hour),
109            minute: clamp_minute(initial_minute),
110            is_24_hour,
111            display_mode,
112            selection: TimePickerSelection::Hour,
113        }
114    }
115
116    /// Returns the selected hour in 24-hour form (0-23).
117    pub fn hour(&self) -> u8 {
118        self.hour
119    }
120
121    /// Returns the selected minute (0-59).
122    pub fn minute(&self) -> u8 {
123        self.minute
124    }
125
126    /// Returns whether the picker uses 24-hour mode.
127    pub fn is_24_hour(&self) -> bool {
128        self.is_24_hour
129    }
130
131    /// Returns the display mode.
132    pub fn display_mode(&self) -> TimePickerDisplayMode {
133        self.display_mode
134    }
135
136    /// Returns the time unit currently selected for adjustment or manual entry.
137    pub fn selection(&self) -> TimePickerSelection {
138        self.selection
139    }
140
141    /// Returns the period for 12-hour mode.
142    pub fn period(&self) -> DayPeriod {
143        if self.hour >= 12 {
144            DayPeriod::Pm
145        } else {
146            DayPeriod::Am
147        }
148    }
149
150    /// Returns the hour to display in the UI.
151    pub fn hour_for_display(&self) -> u8 {
152        if self.is_24_hour {
153            self.hour
154        } else {
155            let hour = self.hour % 12;
156            if hour == 0 { 12 } else { hour }
157        }
158    }
159
160    /// Sets the hour, clamped to 0-23.
161    pub fn set_hour(&mut self, hour: u8) {
162        self.hour = clamp_hour(hour);
163    }
164
165    /// Sets the minute, clamped to 0-59.
166    pub fn set_minute(&mut self, minute: u8) {
167        self.minute = clamp_minute(minute);
168    }
169
170    /// Sets 24-hour mode on or off.
171    pub fn set_is_24_hour(&mut self, is_24_hour: bool) {
172        self.is_24_hour = is_24_hour;
173    }
174
175    /// Sets the display mode.
176    pub fn set_display_mode(&mut self, mode: TimePickerDisplayMode) {
177        self.display_mode = mode;
178    }
179
180    /// Toggles between picker and input modes.
181    pub fn toggle_display_mode(&mut self) {
182        self.display_mode = match self.display_mode {
183            TimePickerDisplayMode::Picker => TimePickerDisplayMode::Input,
184            TimePickerDisplayMode::Input => TimePickerDisplayMode::Picker,
185        };
186    }
187
188    /// Updates the period when in 12-hour mode.
189    pub fn set_period(&mut self, period: DayPeriod) {
190        if self.is_24_hour {
191            return;
192        }
193        let is_pm = self.hour >= 12;
194        match (period, is_pm) {
195            (DayPeriod::Am, true) => self.hour = self.hour.saturating_sub(12),
196            (DayPeriod::Pm, false) => self.hour = (self.hour + 12).min(23),
197            _ => {}
198        }
199    }
200
201    /// Increments the hour by the given step, wrapping around.
202    pub fn increment_hour(&mut self, step: u8) {
203        let step = normalize_step(step, 23);
204        self.hour = ((self.hour as u16 + step as u16) % 24) as u8;
205    }
206
207    /// Decrements the hour by the given step, wrapping around.
208    pub fn decrement_hour(&mut self, step: u8) {
209        let step = normalize_step(step, 23) as i16;
210        let value = (self.hour as i16 - step).rem_euclid(24);
211        self.hour = value as u8;
212    }
213
214    /// Increments the minute by the given step, wrapping around.
215    pub fn increment_minute(&mut self, step: u8) {
216        let step = normalize_step(step, 59);
217        self.minute = ((self.minute as u16 + step as u16) % 60) as u8;
218    }
219
220    /// Decrements the minute by the given step, wrapping around.
221    pub fn decrement_minute(&mut self, step: u8) {
222        let step = normalize_step(step, 59) as i16;
223        let value = (self.minute as i16 - step).rem_euclid(60);
224        self.minute = value as u8;
225    }
226
227    fn snapshot(&self) -> TimePickerSnapshot {
228        TimePickerSnapshot {
229            hour: self.hour,
230            minute: self.minute,
231            is_24_hour: self.is_24_hour,
232            display_mode: self.display_mode,
233            selection: self.selection,
234        }
235    }
236}
237
238impl Default for TimePickerState {
239    fn default() -> Self {
240        let (hour, minute) = current_time_utc();
241        TimePickerState::new(hour, minute, false, TimePickerDisplayMode::Picker)
242    }
243}
244
245#[derive(Clone, PartialEq)]
246struct TimePickerSnapshot {
247    hour: u8,
248    minute: u8,
249    is_24_hour: bool,
250    display_mode: TimePickerDisplayMode,
251    selection: TimePickerSelection,
252}
253
254/// # time_picker
255///
256/// Render a time picker for selecting an hour and minute value.
257///
258/// ## Usage
259///
260/// Use when users need to choose a time for reminders or scheduling.
261///
262/// ## Parameters
263///
264/// - `modifier` — modifier chain applied to the time picker.
265/// - `initial_hour` — initial hour for the internal state.
266/// - `initial_minute` — initial minute for the internal state.
267/// - `is_24_hour` — whether the internal state uses 24-hour mode.
268/// - `display_mode` — initial display mode for the internal state.
269/// - `hour_step` — pointer snapping interval for hour changes.
270/// - `minute_step` — pointer snapping interval for minute changes.
271/// - `state` — optional external state for selected time and display mode.
272/// - `layout` — preferred dial arrangement; defaults to vertical.
273///
274/// Labeled clock values and manual entry select their exact value. Pointer
275/// snapping steps apply to selecting a position on the dial.
276///
277/// ## Examples
278///
279/// ```
280/// # use tessera_ui::tessera;
281/// # #[tessera]
282/// # fn component() {
283/// use tessera_components::time_picker::{TimePickerLayout, time_picker};
284/// # use tessera_components::theme::{MaterialTheme, material_theme};
285///
286/// # material_theme()
287/// #     .theme(|| MaterialTheme::default())
288/// #     .child(|| {
289/// time_picker().layout(TimePickerLayout::Horizontal);
290/// # });
291/// # }
292/// # tessera_ui::testing::layout_test(component).run();
293/// ```
294#[tessera]
295pub fn time_picker(
296    modifier: Option<Modifier>,
297    initial_hour: Option<u8>,
298    initial_minute: Option<u8>,
299    is_24_hour: Option<bool>,
300    display_mode: Option<TimePickerDisplayMode>,
301    hour_step: Option<u8>,
302    minute_step: Option<u8>,
303    state: Option<State<TimePickerState>>,
304    layout: Option<TimePickerLayout>,
305) {
306    let modifier = modifier.unwrap_or_default();
307    let initial_hour = initial_hour.unwrap_or(0);
308    let initial_minute = initial_minute.unwrap_or(0);
309    let is_24_hour = is_24_hour.unwrap_or(false);
310    let display_mode = display_mode.unwrap_or_default();
311    let hour_step = hour_step.unwrap_or(1);
312    let minute_step = minute_step.unwrap_or(1);
313    let state = if let Some(state) = state {
314        state
315    } else {
316        remember(|| TimePickerState::new(initial_hour, initial_minute, is_24_hour, display_mode))
317    };
318    time_picker_content(
319        state,
320        modifier,
321        hour_step,
322        minute_step,
323        layout.unwrap_or_default(),
324    );
325}
326
327#[tessera]
328fn time_picker_content(
329    state: State<TimePickerState>,
330    modifier: Modifier,
331    hour_step: u8,
332    minute_step: u8,
333    arrangement: TimePickerLayout,
334) {
335    let snapshot = state.with(TimePickerState::snapshot);
336    let input = snapshot.display_mode == TimePickerDisplayMode::Input;
337    #[cfg(test)]
338    let modifier = modifier.push_semantics(TimePickerTag("time-picker"));
339    layout()
340        .modifier(modifier)
341        .policy(TimePickerMeasure {
342            arrangement,
343            input,
344            is_24_hour: snapshot.is_24_hour,
345        })
346        .child(move || {
347            time_number(state, TimePickerSelection::Hour, input);
348            let theme = use_context::<MaterialTheme>().unwrap().get();
349            surface()
350                .modifier(Modifier::new().size(Dp(24.0), Dp(if input { 72.0 } else { 80.0 })))
351                .style(Color::TRANSPARENT.into())
352                .content_alignment(Alignment::Center)
353                .child(move || {
354                    text(":").style(theme.typography.display_large);
355                });
356            time_number(state, TimePickerSelection::Minute, input);
357            if !snapshot.is_24_hour {
358                time_period_selector(state, input);
359            }
360            if !input {
361                time_clock(state, hour_step, minute_step);
362            }
363        });
364}
365
366#[cfg(test)]
367#[derive(Clone, PartialEq)]
368struct TimePickerTag(&'static str);
369
370#[cfg(test)]
371impl tessera_ui::SemanticsModifierNode for TimePickerTag {
372    fn apply(
373        &self,
374        node: &mut tessera_ui::AccessibilityNode,
375        _: &mut Option<tessera_ui::AccessibilityActionHandler>,
376    ) {
377        node.key = Some(self.0.to_owned());
378    }
379}
380
381#[derive(Clone, PartialEq)]
382struct TimePickerMeasure {
383    arrangement: TimePickerLayout,
384    input: bool,
385    is_24_hour: bool,
386}
387
388impl tessera_ui::LayoutModifierNode for TimePickerMeasure {
389    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
390        let parent = *scope.parent_constraint().as_ref();
391        let horizontal = !self.input
392            && self.arrangement != TimePickerLayout::Vertical
393            && parent
394                .width
395                .max
396                .is_none_or(|width| width >= Dp(524.0).to_px());
397        let width = if horizontal {
398            524.0
399        } else if self.is_24_hour {
400            256.0
401        } else {
402            280.0
403        };
404        let width = parent.width.clamp(Dp(width).to_px());
405        let field_width = Dp(if self.is_24_hour && !horizontal && !self.input {
406            114.0
407        } else {
408            96.0
409        })
410        .to_px();
411        let field_height = Dp(if self.input { 72.0 } else { 80.0 }).to_px();
412        let fields_width = field_width * 2 + Dp(24.0).to_px();
413        let natural_height = if self.input {
414            Dp(92.0).to_px()
415        } else if horizontal {
416            CLOCK_SIZE.to_px()
417        } else {
418            Dp(372.0).to_px()
419        };
420        let height = parent.height.clamp(natural_height);
421        let mut result = LayoutResult::new(ComputedData { width, height });
422        let children = scope.children();
423        let controls_width = fields_width
424            + if !horizontal && !self.is_24_hour {
425                Dp(64.0).to_px()
426            } else {
427                Px::ZERO
428            };
429        let controls_x = if horizontal {
430            Px::ZERO
431        } else {
432            (width - controls_width).max(Px::ZERO) / 2
433        };
434        let controls_y = if horizontal {
435            (height - CLOCK_SIZE.to_px()).max(Px::ZERO) / 2
436                + Dp(if self.is_24_hour { 88.0 } else { 72.0 }).to_px()
437        } else {
438            Px::ZERO
439        };
440        for (index, child) in children.iter().copied().take(3).enumerate() {
441            let child_width = if index == 1 {
442                Dp(24.0).to_px()
443            } else {
444                field_width
445            };
446            child.measure(
447                scope,
448                &Constraint::new(AxisConstraint::exact(child_width), AxisConstraint::NONE),
449            )?;
450            let x = match index {
451                0 => Px::ZERO,
452                1 => field_width,
453                _ => field_width + Dp(24.0).to_px(),
454            };
455            result.place_child(child, PxPosition::new(controls_x + x, controls_y));
456        }
457        if !self.is_24_hour {
458            let period = children[3];
459            let (period_width, period_height, x, y) = if horizontal {
460                (
461                    Dp(216.0).to_px(),
462                    Dp(38.0).to_px(),
463                    controls_x,
464                    controls_y + field_height + Dp(16.0).to_px(),
465                )
466            } else {
467                (
468                    Dp(52.0).to_px(),
469                    field_height,
470                    controls_x + fields_width + Dp(12.0).to_px(),
471                    controls_y,
472                )
473            };
474            period.measure(
475                scope,
476                &Constraint::new(
477                    AxisConstraint::exact(period_width),
478                    AxisConstraint::exact(period_height),
479                ),
480            )?;
481            result.place_child(period, PxPosition::new(x, y));
482        }
483        if !self.input {
484            let clock = *children.last().expect("clock dial");
485            clock.measure(scope, &Constraint::NONE)?;
486            let (x, y) = if horizontal {
487                (
488                    Dp(268.0).to_px(),
489                    (height - CLOCK_SIZE.to_px()).max(Px::ZERO) / 2,
490                )
491            } else {
492                (
493                    (width - CLOCK_SIZE.to_px()).max(Px::ZERO) / 2,
494                    Dp(116.0).to_px(),
495                )
496            };
497            result.place_child(clock, PxPosition::new(x, y));
498        }
499        Ok(result)
500    }
501}
502
503#[tessera]
504fn time_number(state: State<TimePickerState>, field: TimePickerSelection, input: bool) {
505    let snapshot = state.with(TimePickerState::snapshot);
506    let theme = use_context::<MaterialTheme>().unwrap().get();
507    let scheme = theme.color_scheme;
508    let selected = snapshot.selection == field;
509    let value = if field == TimePickerSelection::Hour {
510        hour_for_display(snapshot.hour, snapshot.is_24_hour)
511    } else {
512        snapshot.minute
513    };
514    let width = if snapshot.is_24_hour && !input {
515        114.0
516    } else {
517        96.0
518    };
519    let height = if input { 72.0 } else { 80.0 };
520    let label = if field == TimePickerSelection::Hour {
521        I18N.text("md3-hour")
522    } else {
523        I18N.text("md3-minute")
524    };
525    let background = if selected && !input {
526        scheme.primary_container
527    } else {
528        scheme.surface_container_highest
529    };
530    let foreground = if selected && !input {
531        scheme.on_primary_container
532    } else {
533        scheme.on_surface
534    };
535    column().children(move || {
536        if input {
537            let controller = remember(|| {
538                let mut controller = TextInputController::new(Dp(45.0), Some(Dp(52.0)));
539                controller.set_text(&format_two_digit(value));
540                controller.with_editor_mut(|editor| {
541                    use glyphon::Edit;
542                    editor.with_buffer_mut(|buffer| {
543                        for line in &mut buffer.lines {
544                            line.set_align(Some(glyphon::cosmic_text::Align::Center));
545                        }
546                    });
547                });
548                controller
549            });
550            let previous = remember(|| format_two_digit(value));
551            let was_focused = remember(|| false);
552            let focused = controller.with(|c| c.focus_handler().is_focused());
553            if focused != was_focused.get() {
554                was_focused.set(focused);
555                if focused {
556                    state.with_mut(|s| s.selection = field);
557                    controller.with_mut(|c| c.select_all());
558                }
559            }
560            if !focused && controller.with(|c| c.text()) != format_two_digit(value) {
561                controller.with_mut(|c| c.set_text(&format_two_digit(value)));
562                previous.set(format_two_digit(value));
563            }
564            text_input()
565                .modifier(Modifier::new().size(Dp(width), Dp(height)))
566                .controller(controller)
567                .min_width(Dp(width))
568                .min_height(Dp(height))
569                .padding(Dp(10.0))
570                .style(theme.typography.display_medium)
571                .font_size(Dp(45.0))
572                .line_height(Dp(52.0))
573                .background_color(background)
574                .focus_background_color(scheme.primary_container)
575                .border_width(Dp(0.0))
576                .focus_border_width(Dp(2.0))
577                .focus_border_color(scheme.primary)
578                .shape(theme.shapes.small)
579                .text_color(foreground)
580                .accessibility_label(label.clone())
581                .single_line(true)
582                .on_change(move |text| {
583                    if text.len() > 2 || !text.bytes().all(|ch| ch.is_ascii_digit()) {
584                        return previous.get();
585                    }
586                    if let Ok(number) = text.parse::<u8>() {
587                        let is_24_hour = state.with(TimePickerState::is_24_hour);
588                        let valid = match field {
589                            TimePickerSelection::Hour => {
590                                if is_24_hour {
591                                    number <= 23
592                                } else {
593                                    (1..=12).contains(&number)
594                                }
595                            }
596                            TimePickerSelection::Minute => number <= 59,
597                        };
598                        if !valid {
599                            return previous.get();
600                        }
601                        state.with_mut(|s| match field {
602                            TimePickerSelection::Hour => s.set_hour(if is_24_hour {
603                                number
604                            } else {
605                                number % 12 + if s.period() == DayPeriod::Pm { 12 } else { 0 }
606                            }),
607                            TimePickerSelection::Minute => s.set_minute(number),
608                        });
609                    }
610                    previous.set(text.clone());
611                    text
612                });
613            spacer().modifier(Modifier::new().height(Dp(4.0)));
614            text(label.clone())
615                .style(theme.typography.body_small)
616                .color(scheme.on_surface_variant);
617        } else {
618            surface()
619                .modifier(
620                    Modifier::new()
621                        .size(Dp(width), Dp(height))
622                        .semantics(SemanticsArgs {
623                            value: Some(format_two_digit(value)),
624                            selected: Some(selected),
625                            ..Default::default()
626                        }),
627                )
628                .style(background.into())
629                .shape(theme.shapes.small)
630                .content_alignment(Alignment::Center)
631                .accessibility_label(label.clone())
632                .on_click(move || {
633                    state.with_mut(|s| s.selection = field);
634                    FocusManager::current().defer_keyboard_until_layout();
635                })
636                .child(move || {
637                    text(format_two_digit(value))
638                        .style(theme.typography.display_large)
639                        .color(foreground);
640                });
641        }
642    });
643}
644
645#[tessera]
646fn time_period_selector(state: State<TimePickerState>, input: bool) {
647    let theme = use_context::<MaterialTheme>()
648        .expect("MaterialTheme must be provided")
649        .get();
650    let scheme = theme.color_scheme;
651    let period = state.with(TimePickerState::period);
652    let requesters = remember(|| [FocusRequester::new(), FocusRequester::new()]).get();
653    let interactions = [
654        remember(InteractionState::new),
655        remember(InteractionState::new),
656    ];
657    layout()
658        .policy(PeriodMeasure { input })
659        .modifier(Modifier::new().semantics(SemanticsArgs {
660            test_tag: cfg!(test).then(|| "time-period".to_owned()),
661            role: Some(Role::RadioGroup),
662            label: Some(I18N.text("md3-am-pm")),
663            merge_descendants: true,
664            ..Default::default()
665        }))
666        .child(move || {
667            layout()
668                .policy(PeriodVisualMeasure { input })
669                .modifier(
670                    Modifier::new()
671                        .clip_shape(theme.shapes.small)
672                        .semantics(SemanticsArgs::clear()),
673                )
674                .child(move || {
675                    for (index, (label, value)) in
676                        [("md3-am", DayPeriod::Am), ("md3-pm", DayPeriod::Pm)]
677                            .into_iter()
678                            .enumerate()
679                    {
680                        let selected = value == period;
681                        surface()
682                            .style(
683                                if selected {
684                                    scheme.tertiary_container
685                                } else {
686                                    Color::TRANSPARENT
687                                }
688                                .into(),
689                            )
690                            .shape(Shape::RECTANGLE)
691                            .interaction_state(interactions[index])
692                            .show_ripple(false)
693                            .block_input(false)
694                            .content_alignment(Alignment::Center)
695                            .child(move || {
696                                text(I18N.text(label))
697                                    .style(theme.typography.title_medium)
698                                    .color(if selected {
699                                        scheme.on_tertiary_container
700                                    } else {
701                                        scheme.on_surface_variant
702                                    });
703                            });
704                    }
705                    surface()
706                        .shape(theme.shapes.small)
707                        .block_input(false)
708                        .style(SurfaceStyle::Outlined {
709                            color: scheme.outline,
710                            width: Dp(1.0),
711                        });
712                    surface()
713                        .shape(Shape::RECTANGLE)
714                        .block_input(false)
715                        .style(scheme.outline.into());
716                });
717            for (index, (label, value)) in [("md3-am", DayPeriod::Am), ("md3-pm", DayPeriod::Pm)]
718                .into_iter()
719                .enumerate()
720            {
721                surface()
722                    .modifier(
723                        Modifier::new()
724                            .semantics(SemanticsArgs::clear())
725                            .push_keyboard_input(PeriodNavigation {
726                                state,
727                                requesters,
728                                index,
729                            }),
730                    )
731                    .style(Color::TRANSPARENT.into())
732                    .shape(Shape::RECTANGLE)
733                    .interaction_state(interactions[index])
734                    .show_state_layer(false)
735                    .show_ripple(false)
736                    .accessibility_label(I18N.text(label))
737                    .focus_requester(requesters[index])
738                    .focus_properties(FocusProperties::new().skip_traversal(value != period))
739                    .accessibility_role(Role::RadioButton)
740                    .accessibility_toggled(if value == period {
741                        Toggled::True
742                    } else {
743                        Toggled::False
744                    })
745                    .on_click(move || {
746                        state.with_mut(|state| state.set_period(value));
747                        FocusManager::current().defer_keyboard_until_layout();
748                    });
749            }
750        });
751}
752
753#[derive(Clone, PartialEq)]
754struct PeriodMeasure {
755    input: bool,
756}
757
758impl tessera_ui::LayoutModifierNode for PeriodMeasure {
759    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
760        let parent = *scope.parent_constraint().as_ref();
761        let width = parent.width.clamp(Dp(52.0).to_px());
762        let height = parent
763            .height
764            .clamp(Dp(if self.input { 72.0 } else { 80.0 }).to_px());
765        let horizontal = width > height;
766        let mut result = LayoutResult::new(ComputedData { width, height });
767        let children = scope.children();
768        children[0].measure(
769            scope,
770            &Constraint::new(AxisConstraint::exact(width), AxisConstraint::exact(height)),
771        )?;
772        result.place_child(children[0], PxPosition::ZERO);
773        let target_height = Dp(48.0).to_px();
774        // Targets remain outside the visual clip so compact toggles keep 48dp
775        // hit areas.
776        for (index, child) in children.iter().copied().skip(1).enumerate() {
777            let first = if horizontal { width / 2 } else { height / 2 };
778            let (w, h, x, y) = if horizontal {
779                (
780                    if index == 0 { first } else { width - first },
781                    target_height,
782                    if index == 0 { Px::ZERO } else { first },
783                    (height - target_height) / 2,
784                )
785            } else {
786                (
787                    width,
788                    target_height,
789                    Px::ZERO,
790                    if index == 0 {
791                        first - target_height
792                    } else {
793                        first
794                    },
795                )
796            };
797            child.measure(
798                scope,
799                &Constraint::new(AxisConstraint::exact(w), AxisConstraint::exact(h)),
800            )?;
801            result.place_child(child, PxPosition::new(x, y));
802        }
803        Ok(result)
804    }
805}
806
807#[derive(Clone, PartialEq)]
808struct PeriodVisualMeasure {
809    input: bool,
810}
811
812impl tessera_ui::LayoutModifierNode for PeriodVisualMeasure {
813    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
814        let parent = *scope.parent_constraint().as_ref();
815        let width = parent.width.clamp(Dp(52.0).to_px());
816        let height = parent
817            .height
818            .clamp(Dp(if self.input { 72.0 } else { 80.0 }).to_px());
819        let horizontal = width > height;
820        let mut result = LayoutResult::new(ComputedData { width, height });
821        let children = scope.children();
822        let first = if horizontal { width / 2 } else { height / 2 };
823        for (index, child) in children.iter().copied().take(2).enumerate() {
824            let (w, h, x, y) = if horizontal {
825                (
826                    if index == 0 { first } else { width - first },
827                    height,
828                    if index == 0 { Px::ZERO } else { first },
829                    Px::ZERO,
830                )
831            } else {
832                (
833                    width,
834                    if index == 0 { first } else { height - first },
835                    Px::ZERO,
836                    if index == 0 { Px::ZERO } else { first },
837                )
838            };
839            child.measure(
840                scope,
841                &Constraint::new(AxisConstraint::exact(w), AxisConstraint::exact(h)),
842            )?;
843            result.place_child(child, PxPosition::new(x, y));
844        }
845        children[2].measure(
846            scope,
847            &Constraint::new(AxisConstraint::exact(width), AxisConstraint::exact(height)),
848        )?;
849        result.place_child(children[2], PxPosition::ZERO);
850        let thickness = Dp(1.0).to_px();
851        let (w, h, x, y) = if horizontal {
852            (thickness, height, first, Px::ZERO)
853        } else {
854            (width, thickness, Px::ZERO, first)
855        };
856        children[3].measure(
857            scope,
858            &Constraint::new(AxisConstraint::exact(w), AxisConstraint::exact(h)),
859        )?;
860        result.place_child(children[3], PxPosition::new(x, y));
861        Ok(result)
862    }
863}
864
865#[derive(Clone, Copy)]
866struct PeriodNavigation {
867    state: State<TimePickerState>,
868    requesters: [FocusRequester; 2],
869    index: usize,
870}
871
872impl KeyboardInputModifierNode for PeriodNavigation {
873    fn on_keyboard_input(&self, mut input: KeyboardInput<'_>) {
874        if input.key_modifiers.control_key()
875            || input.key_modifiers.alt_key()
876            || input.key_modifiers.super_key()
877            || input.key_modifiers.shift_key()
878        {
879            return;
880        }
881        for event in input.keyboard_events.iter() {
882            if event.state != winit::event::ElementState::Pressed {
883                continue;
884            }
885            let index = match event.logical_key {
886                winit::keyboard::Key::Named(
887                    winit::keyboard::NamedKey::ArrowLeft
888                    | winit::keyboard::NamedKey::ArrowRight
889                    | winit::keyboard::NamedKey::ArrowUp
890                    | winit::keyboard::NamedKey::ArrowDown,
891                ) => 1 - self.index,
892                winit::keyboard::Key::Named(winit::keyboard::NamedKey::Home) => 0,
893                winit::keyboard::Key::Named(winit::keyboard::NamedKey::End) => 1,
894                _ => continue,
895            };
896            self.state.with_mut(|state| {
897                state.set_period(if index == 0 {
898                    DayPeriod::Am
899                } else {
900                    DayPeriod::Pm
901                });
902            });
903            self.requesters[index].request_focus();
904            FocusManager::current().defer_keyboard_until_layout();
905            input.block_keyboard();
906            return;
907        }
908    }
909}
910
911fn clock_track(inner: bool) -> ImageVectorData {
912    let radius = if inner {
913        CLOCK_INNER_RADIUS
914    } else {
915        CLOCK_OUTER_RADIUS
916    };
917    let vertices = [
918        [127.0, 128.0 - radius],
919        [129.0, 128.0 - radius],
920        [129.0, 128.0],
921        [127.0, 128.0],
922    ]
923    .map(|position| ImageVectorVertex {
924        position: [position[0] / 256.0, position[1] / 256.0],
925        color: Color::WHITE,
926    });
927    ImageVectorData::new(
928        256.0,
929        256.0,
930        Arc::new(vertices.to_vec()),
931        Arc::new(vec![0, 1, 2, 0, 2, 3]),
932    )
933}
934
935#[derive(Clone, PartialEq)]
936struct ClockLayout {
937    hour: u8,
938    minute: u8,
939    is_24_hour: bool,
940    selection: TimePickerSelection,
941}
942
943impl ClockLayout {
944    fn inner(&self) -> bool {
945        self.selection == TimePickerSelection::Hour
946            && self.is_24_hour
947            && (self.hour == 0 || self.hour > 12)
948    }
949    fn angle(&self) -> f32 {
950        if self.selection == TimePickerSelection::Hour {
951            (self.hour % 12) as f32 * TAU / 12.0
952        } else {
953            self.minute as f32 * TAU / 60.0
954        }
955    }
956}
957
958impl tessera_ui::LayoutModifierNode for ClockLayout {
959    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
960        let size = CLOCK_SIZE.to_px();
961        let mut result = LayoutResult::new(ComputedData {
962            width: size,
963            height: size,
964        });
965        let children = scope.children();
966        let factor = CLOCK_SIZE.to_pixels_f32() / 256.0;
967        for (index, child) in children.iter().copied().enumerate() {
968            let measured = child.measure(scope, &Constraint::NONE)?;
969            let (x, y) = match index {
970                0 | 1 => (128.0, 128.0),
971                2 => {
972                    let radius = if self.inner() {
973                        CLOCK_INNER_RADIUS
974                    } else {
975                        CLOCK_OUTER_RADIUS
976                    };
977                    (
978                        128.0 + radius * self.angle().sin(),
979                        128.0 - radius * self.angle().cos(),
980                    )
981                }
982                3 => (128.0, 128.0),
983                _ => {
984                    let slot = index - 4;
985                    let radius = if slot >= 12 {
986                        CLOCK_INNER_RADIUS
987                    } else {
988                        CLOCK_OUTER_RADIUS
989                    };
990                    let angle = (slot % 12) as f32 * TAU / 12.0;
991                    (128.0 + radius * angle.sin(), 128.0 - radius * angle.cos())
992                }
993            };
994            result.place_child(
995                child,
996                PxPosition::from_f32_xy(
997                    x * factor - measured.width.to_f32() / 2.0,
998                    y * factor - measured.height.to_f32() / 2.0,
999                ),
1000            );
1001        }
1002        Ok(result)
1003    }
1004}
1005
1006#[derive(Clone, Copy)]
1007struct ClockInput {
1008    state: State<TimePickerState>,
1009    dragging: State<bool>,
1010    hour_step: u8,
1011    minute_step: u8,
1012    focus: State<ClockFocus>,
1013}
1014
1015impl PointerInputModifierNode for ClockInput {
1016    fn on_pointer_input(&self, mut input: PointerInput<'_>) {
1017        if input.pointer_changes.is_empty() {
1018            return;
1019        }
1020        if input
1021            .pointer_changes
1022            .iter()
1023            .any(|c| matches!(c.content, CursorEventContent::Cancelled(_)))
1024        {
1025            self.dragging.set(false);
1026            return;
1027        }
1028        let Some(position) = input.cursor_position_rel else {
1029            if input.has_unconsumed_release() {
1030                self.dragging.set(false);
1031            }
1032            return;
1033        };
1034        let pressed = input.pointer_changes.iter().any(|c| {
1035            !c.is_consumed()
1036                && matches!(
1037                    c.content,
1038                    CursorEventContent::Pressed(PressKeyEventType::Left)
1039                )
1040        });
1041        let released = input.has_unconsumed_release();
1042        if pressed {
1043            let width = input.computed_data.width.to_f32();
1044            let height = input.computed_data.height.to_f32();
1045            if position.x < 0.0 || position.y < 0.0 || position.x >= width || position.y >= height {
1046                return;
1047            }
1048            self.dragging.set(true);
1049        }
1050        if !self.dragging.get() {
1051            return;
1052        }
1053        let center = input.computed_data.width.to_f32() / 2.0;
1054        let dx = position.x - center;
1055        let dy = position.y - center;
1056        let angle = dx.atan2(-dy).rem_euclid(TAU);
1057        self.state.with_mut(|s| match s.selection {
1058            TimePickerSelection::Hour => {
1059                let index = (angle * 12.0 / TAU).round() as u8 % 12;
1060                let hour = if s.is_24_hour {
1061                    if dx.hypot(dy) < center * 85.0 / 128.0 {
1062                        if index == 0 { 0 } else { index + 12 }
1063                    } else if index == 0 {
1064                        12
1065                    } else {
1066                        index
1067                    }
1068                } else {
1069                    index + if s.period() == DayPeriod::Pm { 12 } else { 0 }
1070                };
1071                s.set_hour(
1072                    (hour / normalize_step(self.hour_step, 23))
1073                        * normalize_step(self.hour_step, 23),
1074                );
1075                if released {
1076                    s.selection = TimePickerSelection::Minute;
1077                }
1078            }
1079            TimePickerSelection::Minute => {
1080                let step = normalize_step(self.minute_step, 59);
1081                let minute = (angle * 60.0 / TAU).round() as u8 % 60;
1082                s.set_minute((minute / step) * step);
1083            }
1084        });
1085        let slot = self
1086            .state
1087            .with(|state| selected_clock_slot(&state.snapshot()));
1088        let requester = self.focus.with_mut(|focus| {
1089            focus.anchor = slot;
1090            focus.requesters[slot]
1091        });
1092        requester.request_focus();
1093        if released {
1094            self.dragging.set(false);
1095            FocusManager::current().defer_keyboard_until_layout();
1096        }
1097        input.consume_pointer_changes();
1098    }
1099}
1100
1101#[derive(Clone, Copy)]
1102struct ClockFocus {
1103    requesters: [FocusRequester; 24],
1104    anchor: usize,
1105    field: TimePickerSelection,
1106}
1107
1108impl ClockFocus {
1109    fn new(snapshot: &TimePickerSnapshot) -> Self {
1110        Self {
1111            requesters: std::array::from_fn(|_| FocusRequester::new()),
1112            anchor: selected_clock_slot(snapshot),
1113            field: snapshot.selection,
1114        }
1115    }
1116}
1117
1118fn selected_clock_slot(snapshot: &TimePickerSnapshot) -> usize {
1119    match snapshot.selection {
1120        TimePickerSelection::Hour if snapshot.is_24_hour && snapshot.hour == 0 => 12,
1121        TimePickerSelection::Hour if snapshot.is_24_hour && snapshot.hour > 12 => {
1122            snapshot.hour as usize
1123        }
1124        TimePickerSelection::Hour => (snapshot.hour % 12) as usize,
1125        TimePickerSelection::Minute => ((snapshot.minute as f32 / 5.0).round() as usize) % 12,
1126    }
1127}
1128
1129fn clock_value_number(field: TimePickerSelection, index: usize) -> u8 {
1130    match (field, index) {
1131        (TimePickerSelection::Minute, _) => index as u8 * 5,
1132        (TimePickerSelection::Hour, 0) => 12,
1133        (TimePickerSelection::Hour, 12) => 0,
1134        (TimePickerSelection::Hour, _) => index as u8,
1135    }
1136}
1137
1138fn select_clock_slot(state: State<TimePickerState>, index: usize) {
1139    state.with_mut(|state| {
1140        let number = clock_value_number(state.selection, index);
1141        match state.selection {
1142            TimePickerSelection::Hour => {
1143                let hour = if state.is_24_hour {
1144                    number
1145                } else {
1146                    number % 12
1147                        + if state.period() == DayPeriod::Pm {
1148                            12
1149                        } else {
1150                            0
1151                        }
1152                };
1153                state.set_hour(hour);
1154            }
1155            TimePickerSelection::Minute => {
1156                state.set_minute(number);
1157            }
1158        }
1159    });
1160}
1161
1162#[derive(Clone, Copy)]
1163struct ClockNavigation {
1164    focus: State<ClockFocus>,
1165    index: usize,
1166    count: usize,
1167}
1168
1169impl KeyboardInputModifierNode for ClockNavigation {
1170    fn on_keyboard_input(&self, mut input: KeyboardInput<'_>) {
1171        if input.key_modifiers.control_key()
1172            || input.key_modifiers.alt_key()
1173            || input.key_modifiers.super_key()
1174            || input.key_modifiers.shift_key()
1175        {
1176            return;
1177        }
1178        for event in input.keyboard_events.iter() {
1179            if event.state != winit::event::ElementState::Pressed {
1180                continue;
1181            }
1182            let target = match event.logical_key {
1183                winit::keyboard::Key::Named(
1184                    winit::keyboard::NamedKey::ArrowRight | winit::keyboard::NamedKey::ArrowDown,
1185                ) => (self.index + 1) % self.count,
1186                winit::keyboard::Key::Named(
1187                    winit::keyboard::NamedKey::ArrowLeft | winit::keyboard::NamedKey::ArrowUp,
1188                ) => (self.index + self.count - 1) % self.count,
1189                winit::keyboard::Key::Named(winit::keyboard::NamedKey::Home) => 0,
1190                winit::keyboard::Key::Named(winit::keyboard::NamedKey::End) => self.count - 1,
1191                _ => continue,
1192            };
1193            let requester = self.focus.with_mut(|focus| {
1194                focus.anchor = target;
1195                focus.requesters[target]
1196            });
1197            requester.request_focus();
1198            FocusManager::current().defer_keyboard_until_layout();
1199            input.block_keyboard();
1200            return;
1201        }
1202    }
1203}
1204
1205#[tessera]
1206fn time_clock(state: State<TimePickerState>, hour_step: u8, minute_step: u8) {
1207    let snapshot = state.with(TimePickerState::snapshot);
1208    let theme = use_context::<MaterialTheme>().unwrap().get();
1209    let scheme = theme.color_scheme;
1210    let dragging = remember(|| false);
1211    let focus = remember(|| ClockFocus::new(&snapshot));
1212    let count = if snapshot.is_24_hour && snapshot.selection == TimePickerSelection::Hour {
1213        24
1214    } else {
1215        12
1216    };
1217    let current = focus.get();
1218    if current.field != snapshot.selection
1219        || current.anchor >= count
1220        || !current.requesters.iter().any(FocusRequester::is_focused)
1221    {
1222        let anchor = selected_clock_slot(&snapshot);
1223        if current.field != snapshot.selection || current.anchor != anchor {
1224            focus.with_mut(|focus| {
1225                focus.anchor = anchor;
1226                focus.field = snapshot.selection;
1227            });
1228        }
1229    }
1230    let policy = ClockLayout {
1231        hour: snapshot.hour,
1232        minute: snapshot.minute,
1233        is_24_hour: snapshot.is_24_hour,
1234        selection: snapshot.selection,
1235    };
1236    let interaction = ClockInput {
1237        state,
1238        dragging,
1239        hour_step,
1240        minute_step,
1241        focus,
1242    };
1243    layout()
1244        .modifier(
1245            Modifier::new()
1246                .size(CLOCK_SIZE, CLOCK_SIZE)
1247                .semantics(SemanticsArgs {
1248                    test_tag: cfg!(test).then(|| "time-clock".to_owned()),
1249                    role: Some(Role::Group),
1250                    label: Some(
1251                        if snapshot.selection == TimePickerSelection::Hour {
1252                            I18N.text("md3-hours")
1253                        } else {
1254                            I18N.text("md3-minutes")
1255                        }
1256                        .to_owned(),
1257                    ),
1258                    merge_descendants: true,
1259                    ..Default::default()
1260                })
1261                .focus_restorer(Some(focus.with(|focus| focus.requesters[focus.anchor])))
1262                .push_pointer_preview_input(interaction),
1263        )
1264        .policy(policy.clone())
1265        .child(move || {
1266            surface()
1267                .modifier(Modifier::new().size(CLOCK_SIZE, CLOCK_SIZE))
1268                .style(scheme.surface_container_highest.into())
1269                .shape(Shape::Ellipse)
1270                .child(|| {});
1271            let inner = policy.inner();
1272            let track = tessera_ui::remember_with_key(inner, move || clock_track(inner)).get();
1273            icon()
1274                .painter(track)
1275                .size(CLOCK_SIZE)
1276                .rotation(policy.angle().to_degrees())
1277                .tint(scheme.primary)
1278                .tint_mode(TintMode::Solid);
1279            surface()
1280                .modifier(Modifier::new().size(Dp(48.0), Dp(48.0)))
1281                .style(scheme.primary.into())
1282                .shape(Shape::Ellipse)
1283                .content_alignment(Alignment::Center)
1284                .child(move || {
1285                    if snapshot.selection == TimePickerSelection::Minute && snapshot.minute % 5 != 0
1286                    {
1287                        surface()
1288                            .modifier(Modifier::new().size(Dp(4.0), Dp(4.0)))
1289                            .style(scheme.on_primary.into())
1290                            .shape(Shape::Ellipse)
1291                            .child(|| {});
1292                    }
1293                });
1294            surface()
1295                .modifier(Modifier::new().size(Dp(8.0), Dp(8.0)))
1296                .style(scheme.primary.into())
1297                .shape(Shape::Ellipse)
1298                .child(|| {});
1299            for index in 0..count {
1300                clock_value(state, focus, index, count);
1301            }
1302        });
1303}
1304
1305#[tessera]
1306fn clock_value(
1307    state: State<TimePickerState>,
1308    focus: State<ClockFocus>,
1309    index: usize,
1310    count: usize,
1311) {
1312    let snapshot = state.with(TimePickerState::snapshot);
1313    let theme = use_context::<MaterialTheme>().expect("theme").get();
1314    let number = clock_value_number(snapshot.selection, index);
1315    let selected = index == selected_clock_slot(&snapshot);
1316    let (requester, anchor) = focus.with(|focus| (focus.requesters[index], focus.anchor));
1317    let field = if snapshot.selection == TimePickerSelection::Hour {
1318        I18N.text("md3-hour")
1319    } else {
1320        I18N.text("md3-minute")
1321    };
1322    let total = if snapshot.selection == TimePickerSelection::Minute {
1323        60
1324    } else if snapshot.is_24_hour {
1325        24
1326    } else {
1327        12
1328    };
1329    surface()
1330        .modifier(
1331            Modifier::new()
1332                .size(Dp(48.0), Dp(48.0))
1333                .semantics(SemanticsArgs {
1334                    selected: Some(selected),
1335                    ..Default::default()
1336                })
1337                .on_focus_event(move |state: tessera_ui::FocusState| {
1338                    if state.is_focused() && focus.with(|focus| focus.anchor != index) {
1339                        focus.with_mut(|focus| focus.anchor = index);
1340                    }
1341                })
1342                .push_keyboard_input(ClockNavigation {
1343                    focus,
1344                    index,
1345                    count,
1346                }),
1347        )
1348        .style(Color::TRANSPARENT.into())
1349        .shape(Shape::Ellipse)
1350        .show_ripple(false)
1351        .focus_requester(requester)
1352        .focus_properties(FocusProperties::new().skip_traversal(index != anchor))
1353        .accessibility_label(I18N.format(
1354            "md3-clock-value",
1355            [
1356                ("field", field.into()),
1357                ("number", number.into()),
1358                ("total", total.into()),
1359            ],
1360        ))
1361        .on_click(move || select_clock_slot(state, index))
1362        .content_alignment(Alignment::Center)
1363        .child(move || {
1364            text(if snapshot.selection == TimePickerSelection::Minute {
1365                format_two_digit(number)
1366            } else {
1367                number.to_string()
1368            })
1369            .style(theme.typography.body_large)
1370            .color(if selected {
1371                theme.color_scheme.on_primary
1372            } else {
1373                theme.color_scheme.on_surface
1374            });
1375        });
1376}
1377
1378/// # time_picker_dialog
1379///
1380/// Render a time picker dialog body with optional action buttons.
1381///
1382/// ## Usage
1383///
1384/// Use inside `dialog_provider` when presenting a modal time selection flow.
1385///
1386/// ## Parameters
1387///
1388/// - `dialog` — registration scope received from the provider content slot.
1389/// - `state` — state handle used by the embedded time picker.
1390/// - `title` — optional override for the dialog title.
1391/// - `confirm_button` — optional confirm button content.
1392/// - `dismiss_button` — optional dismiss button content.
1393/// - `show_mode_toggle` — whether the display mode toggle is shown.
1394/// - `picker_modifier` — modifier chain applied to the embedded picker.
1395/// - `picker_initial_hour` — initial picker hour.
1396/// - `picker_initial_minute` — initial picker minute.
1397/// - `picker_is_24_hour` — whether the picker uses 24-hour mode.
1398/// - `picker_display_mode` — initial picker display mode.
1399/// - `picker_hour_step` — hour step size for the picker.
1400/// - `picker_minute_step` — minute step size for the picker.
1401/// - `picker_layout` — preferred dial arrangement; defaults to vertical.
1402///
1403/// ## Examples
1404///
1405/// ```
1406/// # use tessera_ui::tessera;
1407/// # #[tessera]
1408/// # fn component() {
1409/// use tessera_ui::remember;
1410/// # use tessera_components::theme::{MaterialTheme, material_theme};
1411/// use tessera_components::time_picker::{TimePickerLayout, TimePickerState, time_picker_dialog};
1412///
1413/// # material_theme()
1414/// #     .theme(|| MaterialTheme::default())
1415/// #     .child(|| {
1416/// let state = remember(TimePickerState::default);
1417/// time_picker_dialog()
1418///     .state(state)
1419///     .picker_layout(TimePickerLayout::Horizontal);
1420/// # });
1421/// # }
1422/// # tessera_ui::testing::layout_test(component).run();
1423/// ```
1424#[tessera]
1425pub fn time_picker_dialog(
1426    dialog: Option<crate::dialog::DialogScope>,
1427    state: Option<State<TimePickerState>>,
1428    #[prop(into)] title: Option<String>,
1429    confirm_button: Option<RenderSlot>,
1430    dismiss_button: Option<RenderSlot>,
1431    show_mode_toggle: Option<bool>,
1432    picker_modifier: Option<Modifier>,
1433    picker_initial_hour: Option<u8>,
1434    picker_initial_minute: Option<u8>,
1435    picker_is_24_hour: Option<bool>,
1436    picker_display_mode: Option<TimePickerDisplayMode>,
1437    picker_hour_step: Option<u8>,
1438    picker_minute_step: Option<u8>,
1439    picker_layout: Option<TimePickerLayout>,
1440) {
1441    crate::dialog::register_dialog_accessibility()
1442        .dialog_optional(dialog)
1443        .label(
1444            title
1445                .clone()
1446                .unwrap_or_else(|| I18N.text("md3-select-time")),
1447        );
1448    let show_mode_toggle = show_mode_toggle.unwrap_or(true);
1449    let picker_modifier = picker_modifier.unwrap_or_default();
1450    let picker_initial_hour = picker_initial_hour.unwrap_or(0);
1451    let picker_initial_minute = picker_initial_minute.unwrap_or(0);
1452    let picker_is_24_hour = picker_is_24_hour.unwrap_or(false);
1453    let picker_display_mode = picker_display_mode.unwrap_or_default();
1454    let picker_hour_step = picker_hour_step.unwrap_or(1);
1455    let picker_minute_step = picker_minute_step.unwrap_or(1);
1456    let state = if let Some(state) = state {
1457        state
1458    } else {
1459        remember(|| {
1460            TimePickerState::new(
1461                picker_initial_hour,
1462                picker_initial_minute,
1463                picker_is_24_hour,
1464                picker_display_mode,
1465            )
1466        })
1467    };
1468    let theme = use_context::<MaterialTheme>().unwrap().get();
1469    let modifier = Modifier::new();
1470    #[cfg(test)]
1471    let modifier = modifier.push_semantics(TimePickerTag("time-picker-dialog"));
1472    layout()
1473        .modifier(modifier)
1474        .policy(TimeDialogMeasure {
1475            arrangement: picker_layout.unwrap_or_default(),
1476            input: state.with(|state| state.display_mode() == TimePickerDisplayMode::Input),
1477        })
1478        .child(move || {
1479            text(
1480                title
1481                    .clone()
1482                    .unwrap_or_else(|| I18N.text("md3-select-time")),
1483            )
1484            .style(theme.typography.label_medium)
1485            .color(theme.color_scheme.on_surface_variant);
1486            time_picker()
1487                .modifier(picker_modifier.clone())
1488                .state(state)
1489                .hour_step(picker_hour_step)
1490                .minute_step(picker_minute_step)
1491                .layout(picker_layout.unwrap_or_default());
1492            row()
1493                .modifier(Modifier::new().fill_max_width().height(Dp(40.0)))
1494                .cross_axis_alignment(CrossAxisAlignment::Center)
1495                .children(move || {
1496                    if show_mode_toggle {
1497                        time_display_mode_toggle(state);
1498                    }
1499                    spacer().modifier(Modifier::new().weight(1.0));
1500                    provide_context(
1501                        || ContentColor {
1502                            current: theme.color_scheme.primary,
1503                        },
1504                        || {
1505                            row()
1506                                .cross_axis_alignment(CrossAxisAlignment::Center)
1507                                .children(move || {
1508                                    if let Some(dismiss) = dismiss_button {
1509                                        dismiss.render();
1510                                    }
1511                                    if dismiss_button.is_some() && confirm_button.is_some() {
1512                                        spacer().modifier(Modifier::new().width(Dp(8.0)));
1513                                    }
1514                                    if let Some(confirm) = confirm_button {
1515                                        confirm.render();
1516                                    }
1517                                });
1518                        },
1519                    );
1520                });
1521        });
1522}
1523
1524#[derive(Clone, PartialEq)]
1525struct TimeDialogMeasure {
1526    arrangement: TimePickerLayout,
1527    input: bool,
1528}
1529
1530impl tessera_ui::LayoutModifierNode for TimeDialogMeasure {
1531    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
1532        let parent = *scope.parent_constraint().as_ref();
1533        let children = scope.children();
1534        let gap = Dp(
1535            if !self.input
1536                && self.arrangement == TimePickerLayout::Horizontal
1537                && parent
1538                    .width
1539                    .max
1540                    .is_none_or(|width| width >= Dp(524.0).to_px())
1541            {
1542                0.0
1543            } else {
1544                20.0
1545            },
1546        )
1547        .to_px();
1548        let title = children[0].probe(
1549            scope,
1550            &Constraint::new(parent.width.without_min(), AxisConstraint::NONE),
1551        )?;
1552        let mut body = children[1].measure(
1553            scope,
1554            &Constraint::new(
1555                parent.width.without_min(),
1556                (parent.height - title.height - gap - Dp(64.0).to_px()).without_min(),
1557            ),
1558        )?;
1559        let resolved_gap = Dp(
1560            if !self.input
1561                && self.arrangement == TimePickerLayout::Horizontal
1562                && body.width >= Dp(524.0).to_px()
1563            {
1564                0.0
1565            } else {
1566                20.0
1567            },
1568        )
1569        .to_px();
1570        if resolved_gap != gap {
1571            body = children[1].measure(
1572                scope,
1573                &Constraint::new(
1574                    parent.width.without_min(),
1575                    (parent.height - title.height - resolved_gap - Dp(64.0).to_px()).without_min(),
1576                ),
1577            )?;
1578        }
1579        let title = children[0].measure(
1580            scope,
1581            &Constraint::new(AxisConstraint::exact(body.width), AxisConstraint::NONE),
1582        )?;
1583        let actions = children[2].measure(
1584            scope,
1585            &Constraint::new(
1586                AxisConstraint::exact(body.width),
1587                AxisConstraint::exact(Dp(40.0).to_px()),
1588            ),
1589        )?;
1590        let body_y = title.height + resolved_gap;
1591        let actions_y = body_y + body.height + Dp(24.0).to_px();
1592        let mut result = LayoutResult::new(ComputedData {
1593            width: parent.width.clamp(body.width),
1594            height: parent.height.clamp(actions_y + actions.height),
1595        });
1596        result.place_child(children[0], PxPosition::ZERO);
1597        result.place_child(children[1], PxPosition::new(Px::ZERO, body_y));
1598        result.place_child(children[2], PxPosition::new(Px::ZERO, actions_y));
1599        Ok(result)
1600    }
1601}
1602
1603#[tessera]
1604fn time_display_mode_toggle(state: State<TimePickerState>) {
1605    let input = state.with(|s| s.display_mode == TimePickerDisplayMode::Input);
1606    icon_button()
1607        .standard()
1608        .icon(if input {
1609            filled::SCHEDULE_SVG
1610        } else {
1611            filled::KEYBOARD_SVG
1612        })
1613        .accessibility_label(if input {
1614            I18N.text("md3-switch-to-clock")
1615        } else {
1616            I18N.text("md3-switch-to-time-input")
1617        })
1618        .on_click(move || {
1619            state.with_mut(TimePickerState::toggle_display_mode);
1620            FocusManager::current().defer_keyboard_until_layout();
1621        });
1622}
1623
1624fn format_two_digit(value: u8) -> String {
1625    format!("{value:02}")
1626}
1627
1628fn hour_for_display(hour: u8, is_24_hour: bool) -> u8 {
1629    if is_24_hour {
1630        hour
1631    } else {
1632        let hour = hour % 12;
1633        if hour == 0 { 12 } else { hour }
1634    }
1635}
1636
1637fn normalize_step(step: u8, max: u8) -> u8 {
1638    if step == 0 { 1 } else { step.min(max) }
1639}
1640
1641fn clamp_hour(hour: u8) -> u8 {
1642    hour.min(23)
1643}
1644
1645fn clamp_minute(minute: u8) -> u8 {
1646    minute.min(59)
1647}
1648
1649fn current_time_utc() -> (u8, u8) {
1650    let duration = SystemTime::now()
1651        .duration_since(UNIX_EPOCH)
1652        .unwrap_or_default();
1653    let secs = duration.as_secs();
1654    let hour = ((secs / 3_600) % 24) as u8;
1655    let minute = ((secs / 60) % 60) as u8;
1656    (hour, minute)
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661
1662    use tessera_ui::{
1663        CursorEventContent, Dp, GestureState, Modifier, PointerChange, PressKeyEventType,
1664        PxPosition, State, remember, tessera,
1665        testing::{
1666            layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
1667            start_layout_test_session,
1668        },
1669    };
1670
1671    use crate::{modifier::ModifierExt as _, theme::material_theme};
1672
1673    use super::{
1674        TimePickerLayout, TimePickerSelection, TimePickerState, time_picker, time_picker_dialog,
1675    };
1676
1677    #[tessera]
1678    fn clock_fixture(
1679        is_24_hour: bool,
1680        output: tessera_ui::testing::TestProbe<Option<State<TimePickerState>>>,
1681    ) {
1682        let state = remember(|| TimePickerState::new(10, 8, is_24_hour, Default::default()));
1683        output.set(Some(state));
1684        material_theme().child(move || {
1685            time_picker().state(state);
1686        });
1687    }
1688
1689    #[tessera]
1690    fn arrangement_fixture(arrangement: TimePickerLayout, is_24_hour: bool) {
1691        material_theme().child(move || {
1692            crate::boxed::boxed().children(move || {
1693                time_picker().layout(arrangement).is_24_hour(is_24_hour);
1694            });
1695        });
1696    }
1697
1698    #[test]
1699    fn dial_arrangement_uses_available_space_and_horizontal_period_geometry() {
1700        for (arrangement, is_24_hour, viewport, size, clock_x, clock_y) in [
1701            (
1702                TimePickerLayout::Vertical,
1703                false,
1704                (800, 600),
1705                (280, 372),
1706                12,
1707                116,
1708            ),
1709            (
1710                TimePickerLayout::Horizontal,
1711                false,
1712                (800, 600),
1713                (524, 256),
1714                268,
1715                0,
1716            ),
1717            (
1718                TimePickerLayout::Horizontal,
1719                true,
1720                (800, 600),
1721                (524, 256),
1722                268,
1723                0,
1724            ),
1725            (
1726                TimePickerLayout::Horizontal,
1727                false,
1728                (400, 600),
1729                (280, 372),
1730                12,
1731                116,
1732            ),
1733            (
1734                TimePickerLayout::Horizontal,
1735                false,
1736                (524, 300),
1737                (524, 256),
1738                268,
1739                0,
1740            ),
1741            (
1742                TimePickerLayout::Horizontal,
1743                false,
1744                (523, 600),
1745                (280, 372),
1746                12,
1747                116,
1748            ),
1749        ] {
1750            let mut session = start_layout_test_session(
1751                layout_test(move || {
1752                    arrangement_fixture(arrangement, is_24_hour);
1753                })
1754                .viewport_px(viewport.0, viewport.1),
1755            );
1756            let snapshot = pump_layout_test_session(&mut session, 0);
1757            snapshot.node("time-picker").size(size.0, size.1);
1758            let clock = snapshot.node("time-clock").bounds();
1759            assert_eq!((clock.x.raw(), clock.y.raw()), (clock_x, clock_y));
1760            if !is_24_hour {
1761                let period = snapshot.node("time-period").bounds();
1762                if size.0 == 524 {
1763                    assert_eq!(
1764                        (
1765                            period.x.raw(),
1766                            period.y.raw(),
1767                            period.width.raw(),
1768                            period.height.raw()
1769                        ),
1770                        (0, 168, 216, 38)
1771                    );
1772                } else {
1773                    assert_eq!(
1774                        (
1775                            period.x.raw(),
1776                            period.y.raw(),
1777                            period.width.raw(),
1778                            period.height.raw()
1779                        ),
1780                        (228, 0, 52, 80)
1781                    );
1782                }
1783            }
1784        }
1785    }
1786
1787    #[tessera]
1788    fn constrained_dialog_fixture() {
1789        material_theme().child(|| {
1790            crate::boxed::boxed().children(|| {
1791                time_picker_dialog()
1792                    .picker_layout(TimePickerLayout::Horizontal)
1793                    .picker_modifier(Modifier::new().width(Dp(400.0)));
1794            });
1795        });
1796    }
1797
1798    #[test]
1799    fn constrained_horizontal_dialog_restores_vertical_header_spacing() {
1800        let mut session = start_layout_test_session(
1801            layout_test(|| {
1802                constrained_dialog_fixture();
1803            })
1804            .viewport_px(800, 600),
1805        );
1806        let snapshot = pump_layout_test_session(&mut session, 0);
1807        snapshot.node("time-picker-dialog").size(400, 472);
1808        let clock = snapshot.node("time-clock").bounds();
1809        assert_eq!((clock.x.raw(), clock.y.raw()), (72, 152));
1810    }
1811
1812    #[test]
1813    fn dial_pointer_selection_distinguishes_inner_hours_and_advances_to_minutes() {
1814        for (is_24_hour, radius, expected_hour) in [(false, 101.0, 3), (true, 69.0, 15)] {
1815            let output = tessera_ui::testing::TestProbe::default();
1816            let probe = output.clone();
1817            let mut session = start_layout_test_session(
1818                layout_test(move || {
1819                    clock_fixture(is_24_hour, probe.clone());
1820                })
1821                .viewport_px(400, 500),
1822            );
1823            let snapshot = pump_layout_test_session(&mut session, 0);
1824            snapshot.node("time-clock").size(256, 256);
1825            let bounds = snapshot.node("time-clock").bounds();
1826            let cursor = Some(PxPosition::from_f32_xy(
1827                bounds.x.to_f32() + bounds.width.to_f32() / 2.0 + radius,
1828                bounds.y.to_f32() + bounds.height.to_f32() / 2.0,
1829            ));
1830            for (index, content) in [
1831                CursorEventContent::Pressed(PressKeyEventType::Left),
1832                CursorEventContent::Released(PressKeyEventType::Left),
1833            ]
1834            .into_iter()
1835            .enumerate()
1836            {
1837                pump_layout_test_session_with_input(
1838                    &mut session,
1839                    (index as u64 + 1) * 16_666_667,
1840                    vec![PointerChange::new(
1841                        tessera_ui::current_frame_time().unwrap(),
1842                        1,
1843                        content,
1844                        GestureState::TapCandidate,
1845                    )],
1846                    cursor,
1847                );
1848            }
1849            let state = output.get().unwrap();
1850            assert_eq!(state.with(TimePickerState::hour), expected_hour);
1851            assert_eq!(state.with(|s| s.selection), TimePickerSelection::Minute);
1852            for (index, content) in [
1853                CursorEventContent::Pressed(PressKeyEventType::Left),
1854                CursorEventContent::Released(PressKeyEventType::Left),
1855            ]
1856            .into_iter()
1857            .enumerate()
1858            {
1859                pump_layout_test_session_with_input(
1860                    &mut session,
1861                    (index as u64 + 3) * 16_666_667,
1862                    vec![PointerChange::new(
1863                        tessera_ui::current_frame_time().unwrap(),
1864                        1,
1865                        content,
1866                        GestureState::TapCandidate,
1867                    )],
1868                    cursor,
1869                );
1870            }
1871            assert_eq!(state.with(TimePickerState::minute), 15);
1872        }
1873    }
1874    #[tessera]
1875    fn time_input_fixture(output: tessera_ui::testing::TestProbe<Option<State<TimePickerState>>>) {
1876        let state = remember(|| {
1877            let mut state = TimePickerState::new(10, 8, false, super::TimePickerDisplayMode::Input);
1878            state.selection = TimePickerSelection::Minute;
1879            state
1880        });
1881        output.set(Some(state));
1882        material_theme().child(move || {
1883            crate::column::column().children(move || {
1884                time_picker().state(state);
1885            });
1886        });
1887    }
1888
1889    #[test]
1890    fn focusing_an_input_updates_the_active_clock_field() {
1891        let output = tessera_ui::testing::TestProbe::default();
1892        let probe = output.clone();
1893        let mut session = start_layout_test_session(
1894            layout_test(move || {
1895                time_input_fixture(probe.clone());
1896            })
1897            .viewport_px(400, 500),
1898        );
1899        pump_layout_test_session(&mut session, 0);
1900        for (index, content) in [
1901            CursorEventContent::Pressed(PressKeyEventType::Left),
1902            CursorEventContent::Released(PressKeyEventType::Left),
1903        ]
1904        .into_iter()
1905        .enumerate()
1906        {
1907            pump_layout_test_session_with_input(
1908                &mut session,
1909                (index as u64 + 1) * 16_666_667,
1910                vec![PointerChange::new(
1911                    tessera_ui::current_frame_time().unwrap(),
1912                    1,
1913                    content,
1914                    GestureState::TapCandidate,
1915                )],
1916                Some(PxPosition::from_f32_xy(48.0, 36.0)),
1917            );
1918        }
1919        for frame in 3..6 {
1920            pump_layout_test_session(&mut session, frame * 16_666_667);
1921        }
1922        assert_eq!(
1923            output.get().unwrap().with(|s| s.selection),
1924            TimePickerSelection::Hour
1925        );
1926    }
1927}