1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum TimePickerSelection {
52 #[default]
54 Hour,
55 Minute,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum TimePickerDisplayMode {
62 #[default]
64 Picker,
65 Input,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum TimePickerLayout {
72 #[default]
74 Vertical,
75 Horizontal,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum DayPeriod {
84 Am,
86 Pm,
88}
89
90pub 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 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 pub fn hour(&self) -> u8 {
118 self.hour
119 }
120
121 pub fn minute(&self) -> u8 {
123 self.minute
124 }
125
126 pub fn is_24_hour(&self) -> bool {
128 self.is_24_hour
129 }
130
131 pub fn display_mode(&self) -> TimePickerDisplayMode {
133 self.display_mode
134 }
135
136 pub fn selection(&self) -> TimePickerSelection {
138 self.selection
139 }
140
141 pub fn period(&self) -> DayPeriod {
143 if self.hour >= 12 {
144 DayPeriod::Pm
145 } else {
146 DayPeriod::Am
147 }
148 }
149
150 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 pub fn set_hour(&mut self, hour: u8) {
162 self.hour = clamp_hour(hour);
163 }
164
165 pub fn set_minute(&mut self, minute: u8) {
167 self.minute = clamp_minute(minute);
168 }
169
170 pub fn set_is_24_hour(&mut self, is_24_hour: bool) {
172 self.is_24_hour = is_24_hour;
173 }
174
175 pub fn set_display_mode(&mut self, mode: TimePickerDisplayMode) {
177 self.display_mode = mode;
178 }
179
180 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 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 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 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 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 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#[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 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#[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}