1pub mod dial;
108pub mod input;
109
110pub use dial::{DIAL_SIZE, DIAL_SLOTS, TimeDialView, TimeDialWidget, time_dial};
111pub use input::{TimeInputView, TimeInputWidget, time_input};
112
113use std::rc::Rc;
114
115use frust::authoring::text::{FontWeight, LineHeight};
116use frust::authoring::{
117 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
118 LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, ThemeTextColor, ThemeTextType, View, Widget,
119 any, build_child, rebuild_child, route_event, route_event_single, teardown_child,
120 visit_children,
121};
122use frust::{NavigatorController, PopResult, text};
123use kurbo::{Point, Size};
124
125use crate::button::{button, text_button};
126use crate::divider::divider;
127use crate::icon_button::icon_button;
128use crate::overlay::{
129 ModalDismiss, OverlayModalConfig, OverlayModalContent, OverlayModalView, OverlayModalWidget,
130 overlay_modal, show_overlay_modal,
131};
132
133const PADDING: f64 = 24.0;
136const ACTION_GAP: f64 = 8.0;
139pub const HEADER_LANDSCAPE_WIDTH: f64 = 176.0;
141pub const DIALOG_PORTRAIT_WIDTH: f64 = 328.0;
144pub const DIALOG_LANDSCAPE_WIDTH: f64 = 544.0;
147
148const HELP_SIZE: f32 = 14.0;
150const HELP_LINE_HEIGHT: f32 = 20.0;
151const HELP_WEIGHT: FontWeight = FontWeight::MEDIUM;
152const TITLE_SIZE: f32 = 32.0;
155const TITLE_LINE_HEIGHT: f32 = 40.0;
156const TITLE_SHORT_SIZE: f32 = 24.0;
159const TITLE_SHORT_LINE_HEIGHT: f32 = 32.0;
160const DIVIDER_THICKNESS: f64 = 1.0;
162
163#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
174pub struct TimeOfDay {
175 hour: u8,
176 minute: u8,
177}
178
179impl TimeOfDay {
180 pub const MIDNIGHT: TimeOfDay = TimeOfDay { hour: 0, minute: 0 };
182
183 pub fn new(hour: u8, minute: u8) -> Self {
185 TimeOfDay {
186 hour: hour.min(23),
187 minute: minute.min(59),
188 }
189 }
190
191 pub fn from_hour_12(hour_12: u8, pm: bool, minute: u8) -> Self {
194 let base = hour_12 % 12;
195 TimeOfDay::new(if pm { base + 12 } else { base }, minute)
196 }
197
198 pub fn hour(self) -> u8 {
200 self.hour
201 }
202
203 pub fn minute(self) -> u8 {
205 self.minute
206 }
207
208 pub fn is_pm(self) -> bool {
210 self.hour >= 12
211 }
212
213 pub fn hour_of_12(self) -> u8 {
215 let value = self.hour % 12;
216 if value == 0 { 12 } else { value }
217 }
218
219 pub fn with_hour(self, hour: u8) -> Self {
221 TimeOfDay::new(hour, self.minute)
222 }
223
224 pub fn with_minute(self, minute: u8) -> Self {
226 TimeOfDay::new(self.hour, minute)
227 }
228
229 pub fn with_period(self, pm: bool) -> Self {
232 TimeOfDay::from_hour_12(self.hour_of_12(), pm, self.minute)
233 }
234
235 pub fn hour_label(self, use_24_hour: bool) -> String {
238 let value = if use_24_hour {
239 self.hour
240 } else {
241 self.hour_of_12()
242 };
243 format!("{value:02}")
244 }
245
246 pub fn minute_label(self) -> String {
248 format!("{:02}", self.minute)
249 }
250
251 pub fn format(self, use_24_hour: bool, strings: &TimePickerStrings) -> String {
256 if use_24_hour {
257 format!("{:02}:{:02}", self.hour, self.minute)
258 } else {
259 let period = if self.is_pm() {
260 &strings.pm
261 } else {
262 &strings.am
263 };
264 format!("{}:{:02} {}", self.hour_of_12(), self.minute, period)
265 }
266 }
267}
268
269#[derive(Clone, Debug, Default, PartialEq, Eq)]
272pub struct TimeEntry {
273 pub hour_text: String,
275 pub minute_text: String,
277 pub pm: bool,
279}
280
281impl TimeEntry {
282 pub fn from_time(time: TimeOfDay, use_24_hour: bool) -> Self {
284 TimeEntry {
285 hour_text: time.hour_label(use_24_hour),
286 minute_text: time.minute_label(),
287 pm: time.is_pm(),
288 }
289 }
290
291 pub fn empty() -> Self {
293 TimeEntry::default()
294 }
295
296 pub fn with_hour_text(mut self, text: impl Into<String>) -> Self {
298 self.hour_text = text.into();
299 self
300 }
301
302 pub fn with_minute_text(mut self, text: impl Into<String>) -> Self {
304 self.minute_text = text.into();
305 self
306 }
307
308 pub fn with_pm(mut self, pm: bool) -> Self {
310 self.pm = pm;
311 self
312 }
313
314 pub fn hour_is_valid(&self, use_24_hour: bool) -> bool {
317 match self.hour_text.trim().parse::<i64>() {
318 Ok(value) if use_24_hour => (0..=23).contains(&value),
319 Ok(value) => (1..=12).contains(&value),
320 Err(_) => false,
321 }
322 }
323
324 pub fn minute_is_valid(&self) -> bool {
326 matches!(self.minute_text.trim().parse::<i64>(), Ok(v) if (0..=59).contains(&v))
327 }
328
329 pub fn parse(&self, use_24_hour: bool) -> Option<TimeOfDay> {
332 if !self.hour_is_valid(use_24_hour) || !self.minute_is_valid() {
333 return None;
334 }
335 let hour: i64 = self.hour_text.trim().parse().ok()?;
336 let minute: i64 = self.minute_text.trim().parse().ok()?;
337 let (hour, minute) = (hour as u8, minute as u8);
338 Some(if use_24_hour {
339 TimeOfDay::new(hour, minute)
340 } else {
341 TimeOfDay::from_hour_12(hour, self.pm, minute)
342 })
343 }
344}
345
346#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
349pub enum TimePickerEntryMode {
350 #[default]
352 Dial,
353 Input,
355 DialOnly,
357 InputOnly,
359}
360
361impl TimePickerEntryMode {
362 pub fn is_input(self) -> bool {
364 matches!(
365 self,
366 TimePickerEntryMode::Input | TimePickerEntryMode::InputOnly
367 )
368 }
369
370 pub fn shows_toggle(self) -> bool {
372 matches!(self, TimePickerEntryMode::Dial | TimePickerEntryMode::Input)
373 }
374
375 pub fn toggled(self) -> Self {
378 match self {
379 TimePickerEntryMode::Dial => TimePickerEntryMode::Input,
380 TimePickerEntryMode::Input => TimePickerEntryMode::Dial,
381 fixed => fixed,
382 }
383 }
384}
385
386#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
388pub enum TimePickerMode {
389 #[default]
391 Hour,
392 Minute,
394}
395
396#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
398pub enum TimePickerOrientation {
399 #[default]
401 Portrait,
402 Landscape,
404}
405
406impl TimePickerOrientation {
407 pub fn for_size(size: Size) -> Self {
410 if size.width > size.height {
411 TimePickerOrientation::Landscape
412 } else {
413 TimePickerOrientation::Portrait
414 }
415 }
416
417 pub fn is_landscape(self) -> bool {
419 matches!(self, TimePickerOrientation::Landscape)
420 }
421
422 pub fn panel_width(self) -> f64 {
424 if self.is_landscape() {
425 DIALOG_LANDSCAPE_WIDTH
426 } else {
427 DIALOG_PORTRAIT_WIDTH
428 }
429 }
430}
431
432#[derive(Clone, Debug, PartialEq, Eq)]
436pub struct TimePickerStrings {
437 pub dial_help: String,
439 pub input_help: String,
441 pub hour_label: String,
443 pub minute_label: String,
445 pub invalid_time: String,
447 pub am: String,
449 pub pm: String,
451 pub cancel: String,
453 pub confirm: String,
455 pub dial_mode_tooltip: String,
457 pub input_mode_tooltip: String,
460 pub hour_mode_announcement: String,
462 pub minute_mode_announcement: String,
465}
466
467impl Default for TimePickerStrings {
468 fn default() -> Self {
469 TimePickerStrings {
470 dial_help: "Select time".into(),
471 input_help: "Enter time".into(),
472 hour_label: "Hour".into(),
473 minute_label: "Minute".into(),
474 invalid_time: "Enter a valid time".into(),
475 am: "AM".into(),
476 pm: "PM".into(),
477 cancel: "Cancel".into(),
478 confirm: "OK".into(),
479 dial_mode_tooltip: "Switch to dial picker mode".into(),
480 input_mode_tooltip: "Switch to text input mode".into(),
481 hour_mode_announcement: "Select hours".into(),
482 minute_mode_announcement: "Select minutes".into(),
483 }
484 }
485}
486
487type OnAction<State> = Rc<dyn Fn(&mut State)>;
493
494struct TimePickerPanelView<State: 'static> {
497 help: String,
498 title: String,
499 short_title: bool,
500 orientation: TimePickerOrientation,
501 input_mode: bool,
502 mode_button: Option<PanelModeButton<State>>,
503 body: Rc<AnyView<State>>,
504 cancel_label: String,
505 confirm_label: String,
506 on_cancel: Option<OnAction<State>>,
507 on_confirm: Option<OnAction<State>>,
508}
509
510struct PanelModeButton<State: 'static> {
512 to_input: bool,
513 tooltip: String,
514 on_press: OnAction<State>,
515}
516
517struct TimePickerPanelWidget {
519 help: ChildPod,
520 title: ChildPod,
521 mode_button: Option<ChildPod>,
522 divider: ChildPod,
523 body: ChildPod,
524 actions: Vec<ChildPod>,
525 orientation: TimePickerOrientation,
526 input_mode: bool,
527}
528
529fn help_view<State: 'static>(s: &str) -> AnyView<State> {
531 any::<State, _>(
532 text(s.to_string())
533 .size(HELP_SIZE)
534 .weight(HELP_WEIGHT)
535 .line_height(LineHeight::Absolute(HELP_LINE_HEIGHT))
536 .themed_role(ThemeTextColor::OnSurfaceVariant)
537 .themed_family(ThemeTextType::LabelLarge),
538 )
539}
540
541fn title_view<State: 'static>(s: &str, short: bool) -> AnyView<State> {
544 let (size, line_height, family) = if short {
545 (
546 TITLE_SHORT_SIZE,
547 TITLE_SHORT_LINE_HEIGHT,
548 ThemeTextType::HeadlineSmall,
549 )
550 } else {
551 (TITLE_SIZE, TITLE_LINE_HEIGHT, ThemeTextType::HeadlineLarge)
552 };
553 any::<State, _>(
554 text(s.to_string())
555 .size(size)
556 .line_height(LineHeight::Absolute(line_height))
557 .themed_family(family),
558 )
559}
560
561fn divider_view<State: 'static>(vertical: bool) -> AnyView<State> {
562 let d = divider();
563 any::<State, _>(if vertical { d.vertical() } else { d })
564}
565
566impl<State: 'static> TimePickerPanelView<State> {
567 fn mode_button_view(&self) -> Option<AnyView<State>> {
568 let spec = self.mode_button.as_ref()?;
569 let on_press = spec.on_press.clone();
570 let glyph = if spec.to_input {
571 crate::icons::EDIT_OUTLINED
572 } else {
573 crate::icons::SCHEDULE
574 };
575 Some(any(icon_button(
576 any(frust::icon(glyph)),
577 move |state: &mut State| {
578 on_press(state);
579 },
580 )
581 .semantic_label(spec.tooltip.clone())))
582 }
583
584 fn action_views(&self) -> Vec<AnyView<State>> {
585 let cancel = self.on_cancel.clone();
586 let confirm = self.on_confirm.clone();
587 vec![
588 any(text_button(
589 self.cancel_label.clone(),
590 move |state: &mut State| {
591 if let Some(cancel) = &cancel {
592 cancel(state);
593 }
594 },
595 )),
596 any(button(
597 self.confirm_label.clone(),
598 move |state: &mut State| {
599 if let Some(confirm) = &confirm {
600 confirm(state);
601 }
602 },
603 )),
604 ]
605 }
606}
607
608impl<State: 'static> View<State> for TimePickerPanelView<State> {
609 type Element = TimePickerPanelWidget;
610
611 fn build(&self, ctx: &mut BuildCtx<'_>) -> TimePickerPanelWidget {
612 TimePickerPanelWidget {
613 help: build_child(&help_view::<State>(&self.help), ctx),
614 title: build_child(&title_view::<State>(&self.title, self.short_title), ctx),
615 mode_button: self.mode_button_view().map(|v| build_child(&v, ctx)),
616 divider: build_child(÷r_view::<State>(self.orientation.is_landscape()), ctx),
617 body: build_child(self.body.as_ref(), ctx),
618 actions: self
619 .action_views()
620 .iter()
621 .map(|v| build_child(v, ctx))
622 .collect(),
623 orientation: self.orientation,
624 input_mode: self.input_mode,
625 }
626 }
627
628 fn rebuild(
629 &self,
630 prev: &Self,
631 element: &mut TimePickerPanelWidget,
632 ctx: &mut BuildCtx<'_>,
633 ) -> ChangeFlags {
634 let mut flags = rebuild_child(
635 &help_view::<State>(&prev.help),
636 &help_view::<State>(&self.help),
637 &mut element.help,
638 ctx,
639 );
640 flags |= rebuild_child(
641 &title_view::<State>(&prev.title, prev.short_title),
642 &title_view::<State>(&self.title, self.short_title),
643 &mut element.title,
644 ctx,
645 );
646
647 match (prev.mode_button_view(), self.mode_button_view()) {
648 (Some(p), Some(n)) => {
649 if let Some(pod) = element.mode_button.as_mut() {
650 flags |= rebuild_child(&p, &n, pod, ctx);
651 }
652 }
653 (Some(p), None) => {
654 if let Some(pod) = element.mode_button.as_mut() {
655 teardown_child(&p, pod, ctx);
656 }
657 element.mode_button = None;
658 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
659 }
660 (None, Some(n)) => {
661 element.mode_button = Some(build_child(&n, ctx));
662 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
663 }
664 (None, None) => {}
665 }
666
667 flags |= rebuild_child(
668 ÷r_view::<State>(prev.orientation.is_landscape()),
669 ÷r_view::<State>(self.orientation.is_landscape()),
670 &mut element.divider,
671 ctx,
672 );
673 flags |= rebuild_child(
674 prev.body.as_ref(),
675 self.body.as_ref(),
676 &mut element.body,
677 ctx,
678 );
679 for ((p, n), pod) in prev
680 .action_views()
681 .iter()
682 .zip(self.action_views().iter())
683 .zip(element.actions.iter_mut())
684 {
685 flags |= rebuild_child(p, n, pod, ctx);
686 }
687
688 if element.orientation != self.orientation || element.input_mode != self.input_mode {
689 element.orientation = self.orientation;
690 element.input_mode = self.input_mode;
691 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
692 }
693 flags
694 }
695
696 fn teardown(&self, element: &mut TimePickerPanelWidget, ctx: &mut BuildCtx<'_>) {
697 teardown_child(&help_view::<State>(&self.help), &mut element.help, ctx);
698 teardown_child(
699 &title_view::<State>(&self.title, self.short_title),
700 &mut element.title,
701 ctx,
702 );
703 if let (Some(view), Some(pod)) = (self.mode_button_view(), element.mode_button.as_mut()) {
704 teardown_child(&view, pod, ctx);
705 }
706 teardown_child(
707 ÷r_view::<State>(self.orientation.is_landscape()),
708 &mut element.divider,
709 ctx,
710 );
711 teardown_child(self.body.as_ref(), &mut element.body, ctx);
712 for (view, pod) in self.action_views().iter().zip(element.actions.iter_mut()) {
713 teardown_child(view, pod, ctx);
714 }
715 }
716}
717
718impl TimePickerPanelWidget {
719 fn layout_help_row(&mut self, ctx: &mut LayoutCtx, x: f64, y: f64, width: f64) -> f64 {
723 let button_w = match self.mode_button.as_mut() {
724 Some(pod) => {
725 pod.layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)))
726 .width
727 }
728 None => 0.0,
729 };
730 let help_w = (width - button_w).max(0.0);
731 let help_h = self
732 .help
733 .layout_child(
734 ctx,
735 &BoxConstraints::new(Size::new(help_w, 0.0), Size::new(help_w, f64::INFINITY)),
736 )
737 .height;
738 let button_h = self.mode_button.as_ref().map_or(0.0, |p| p.size().height);
739 let row_h = help_h.max(button_h);
740 self.help
741 .set_origin(Point::new(x, y + (row_h - help_h) / 2.0));
742 if let Some(pod) = self.mode_button.as_mut() {
743 pod.set_origin(Point::new(
744 x + width - button_w,
745 y + (row_h - button_h) / 2.0,
746 ));
747 }
748 row_h
749 }
750
751 fn layout_actions(&mut self, ctx: &mut LayoutCtx, x: f64, y: f64, width: f64) -> f64 {
754 let loose = BoxConstraints::loose(Size::new(width, f64::INFINITY));
755 let mut row_h: f64 = 0.0;
756 for pod in &mut self.actions {
757 let s = pod.layout_child(ctx, &loose);
758 row_h = row_h.max(s.height);
759 }
760 let mut right = x + width;
761 for pod in self.actions.iter_mut().rev() {
762 let w = pod.size().width;
763 right -= w;
764 pod.set_origin(Point::new(right, y));
765 right -= ACTION_GAP;
766 }
767 row_h
768 }
769
770 fn body_inset(&self) -> f64 {
774 if self.input_mode { PADDING } else { 0.0 }
775 }
776
777 fn layout_portrait(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, width: f64) -> Size {
778 let inner = (width - 2.0 * PADDING).max(0.0);
779 let mut y = PADDING;
780 y += self.layout_help_row(ctx, PADDING, y, inner);
781 let title_h = self
782 .title
783 .layout_child(
784 ctx,
785 &BoxConstraints::new(Size::new(inner, 0.0), Size::new(inner, f64::INFINITY)),
786 )
787 .height;
788 self.title.set_origin(Point::new(PADDING, y));
789 y += title_h + PADDING;
790
791 self.divider.layout_child(
792 ctx,
793 &BoxConstraints::tight(Size::new(width, DIVIDER_THICKNESS)),
794 );
795 self.divider.set_origin(Point::new(0.0, y));
796 y += DIVIDER_THICKNESS;
797
798 let inset = self.body_inset();
799 let body_w = (width - 2.0 * inset).max(0.0);
800 y += PADDING;
801 let body_h = self
802 .body
803 .layout_child(
804 ctx,
805 &BoxConstraints::new(Size::new(body_w, 0.0), Size::new(body_w, f64::INFINITY)),
806 )
807 .height;
808 self.body.set_origin(Point::new(inset, y));
809 y += body_h + PADDING;
810
811 y += self.layout_actions(ctx, PADDING, y, inner);
812 y += PADDING;
813
814 bc.constrain(Size::new(width, y))
815 }
816
817 fn layout_landscape(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, width: f64) -> Size {
818 let header_w = HEADER_LANDSCAPE_WIDTH.min(width);
819 let header_inner = (header_w - 2.0 * PADDING).max(0.0);
820 let mut header_y = PADDING;
821 header_y += self.layout_help_row(ctx, PADDING, header_y, header_inner);
822 let title_h = self
823 .title
824 .layout_child(
825 ctx,
826 &BoxConstraints::new(
827 Size::new(header_inner, 0.0),
828 Size::new(header_inner, f64::INFINITY),
829 ),
830 )
831 .height;
832 self.title.set_origin(Point::new(PADDING, header_y));
833 header_y += title_h + PADDING;
834
835 let body_x = header_w + DIVIDER_THICKNESS;
836 let column_w = (width - body_x).max(0.0);
837 let inset = self.body_inset();
838 let body_w = (column_w - 2.0 * inset).max(0.0);
839 let mut column_y = PADDING;
840 let body_h = self
841 .body
842 .layout_child(
843 ctx,
844 &BoxConstraints::new(Size::new(body_w, 0.0), Size::new(body_w, f64::INFINITY)),
845 )
846 .height;
847 self.body.set_origin(Point::new(body_x + inset, column_y));
848 column_y += body_h + PADDING;
849 let actions_w = (column_w - 2.0 * PADDING).max(0.0);
850 column_y += self.layout_actions(ctx, body_x + PADDING, column_y, actions_w);
851 column_y += PADDING;
852
853 let height = header_y.max(column_y);
854 self.divider.layout_child(
855 ctx,
856 &BoxConstraints::tight(Size::new(DIVIDER_THICKNESS, height)),
857 );
858 self.divider.set_origin(Point::new(header_w, 0.0));
859
860 bc.constrain(Size::new(width, height))
861 }
862}
863
864impl Widget for TimePickerPanelWidget {
865 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
866 let width = bc.max().width.max(bc.min().width);
868 if self.orientation.is_landscape() {
869 self.layout_landscape(ctx, bc, width)
870 } else {
871 self.layout_portrait(ctx, bc, width)
872 }
873 }
874
875 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
876 self.help.paint_child(ctx, scene);
877 self.title.paint_child(ctx, scene);
878 if let Some(pod) = self.mode_button.as_mut() {
879 pod.paint_child(ctx, scene);
880 }
881 self.divider.paint_child(ctx, scene);
882 self.body.paint_child(ctx, scene);
883 for pod in &mut self.actions {
884 pod.paint_child(ctx, scene);
885 }
886 }
887
888 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
889 if route_event_single(&mut self.body, ctx, event) == EventResult::Handled {
890 return EventResult::Handled;
891 }
892 if let Some(pod) = self.mode_button.as_mut()
893 && route_event_single(pod, ctx, event) == EventResult::Handled
894 {
895 return EventResult::Handled;
896 }
897 route_event(&mut self.actions, ctx, event)
898 }
899
900 fn semantics(&self, ctx: &mut SemanticsCtx) {
901 self.help.semantics_child(ctx);
902 self.title.semantics_child(ctx);
903 if let Some(pod) = &self.mode_button {
904 pod.semantics_child(ctx);
905 }
906 self.body.semantics_child(ctx);
907 for pod in &self.actions {
908 pod.semantics_child(ctx);
909 }
910 }
911
912 visit_children!(help, title, mode_button, divider, body, actions);
913}
914
915type OnValue<State, T> = Rc<dyn Fn(&mut State, T)>;
921
922pub struct TimePickerView<State: 'static> {
924 value: TimeOfDay,
925 entry: Option<TimeEntry>,
926 entry_mode: TimePickerEntryMode,
927 orientation: TimePickerOrientation,
928 use_24_hour: bool,
929 autovalidate: bool,
930 strings: Rc<TimePickerStrings>,
931 on_change: Option<OnValue<State, TimeOfDay>>,
932 on_entry_change: Option<OnValue<State, TimeEntry>>,
933 on_entry_mode_change: Option<OnValue<State, TimePickerEntryMode>>,
934 on_confirm: Option<OnValue<State, Option<TimeOfDay>>>,
935 on_cancel: Option<OnAction<State>>,
936 dismissable: bool,
937 on_dismiss: Option<OnAction<State>>,
938 dismiss_handle: Option<ModalDismiss>,
939}
940
941pub fn time_picker<State: 'static>(value: TimeOfDay) -> TimePickerView<State> {
946 TimePickerView {
947 value,
948 entry: None,
949 entry_mode: TimePickerEntryMode::default(),
950 orientation: TimePickerOrientation::default(),
951 use_24_hour: false,
952 autovalidate: false,
953 strings: Rc::new(TimePickerStrings::default()),
954 on_change: None,
955 on_entry_change: None,
956 on_entry_mode_change: None,
957 on_confirm: None,
958 on_cancel: None,
959 dismissable: true,
960 on_dismiss: None,
961 dismiss_handle: None,
962 }
963}
964
965#[allow(non_snake_case)]
967pub fn TimePicker<State: 'static>(value: TimeOfDay) -> TimePickerView<State> {
968 time_picker(value)
969}
970
971impl<State: 'static> TimePickerView<State> {
972 pub fn value(mut self, value: TimeOfDay) -> Self {
974 self.value = value;
975 self
976 }
977
978 pub fn entry(mut self, entry: TimeEntry) -> Self {
981 self.entry = Some(entry);
982 self
983 }
984
985 pub fn entry_mode(mut self, entry_mode: TimePickerEntryMode) -> Self {
988 self.entry_mode = entry_mode;
989 self
990 }
991
992 pub fn orientation(mut self, orientation: TimePickerOrientation) -> Self {
994 self.orientation = orientation;
995 self
996 }
997
998 pub fn use_24_hour(mut self, use_24_hour: bool) -> Self {
1001 self.use_24_hour = use_24_hour;
1002 self
1003 }
1004
1005 pub fn autovalidate(mut self, autovalidate: bool) -> Self {
1009 self.autovalidate = autovalidate;
1010 self
1011 }
1012
1013 pub fn strings(mut self, strings: TimePickerStrings) -> Self {
1015 self.strings = Rc::new(strings);
1016 self
1017 }
1018
1019 pub fn on_change<F: Fn(&mut State, TimeOfDay) + 'static>(mut self, on_change: F) -> Self {
1021 self.on_change = Some(Rc::new(on_change));
1022 self
1023 }
1024
1025 pub fn on_entry_change<F: Fn(&mut State, TimeEntry) + 'static>(mut self, on_change: F) -> Self {
1028 self.on_entry_change = Some(Rc::new(on_change));
1029 self
1030 }
1031
1032 pub fn on_entry_mode_change<F: Fn(&mut State, TimePickerEntryMode) + 'static>(
1034 mut self,
1035 on_change: F,
1036 ) -> Self {
1037 self.on_entry_mode_change = Some(Rc::new(on_change));
1038 self
1039 }
1040
1041 pub fn on_confirm<F: Fn(&mut State, Option<TimeOfDay>) + 'static>(
1047 mut self,
1048 on_confirm: F,
1049 ) -> Self {
1050 self.on_confirm = Some(Rc::new(on_confirm));
1051 self
1052 }
1053
1054 pub fn on_cancel<F: Fn(&mut State) + 'static>(mut self, on_cancel: F) -> Self {
1056 self.on_cancel = Some(Rc::new(on_cancel));
1057 self
1058 }
1059
1060 pub fn dismissable(mut self, dismissable: bool) -> Self {
1063 self.dismissable = dismissable;
1064 self
1065 }
1066
1067 pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
1070 self.on_dismiss = Some(Rc::new(on_dismiss));
1071 self
1072 }
1073
1074 pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
1083 self.dismiss_handle = Some(dismiss);
1084 self
1085 }
1086
1087 fn resolved_entry(&self) -> TimeEntry {
1090 self.entry
1091 .clone()
1092 .unwrap_or_else(|| TimeEntry::from_time(self.value, self.use_24_hour))
1093 }
1094
1095 fn confirmed(&self) -> Option<TimeOfDay> {
1097 if self.entry_mode.is_input() {
1098 self.resolved_entry().parse(self.use_24_hour)
1099 } else {
1100 Some(self.value)
1101 }
1102 }
1103
1104 fn body_view(&self) -> AnyView<State> {
1105 if self.entry_mode.is_input() {
1106 let on_change = self.on_entry_change.clone();
1107 any(
1108 time_input(self.resolved_entry(), move |state: &mut State, e| {
1109 if let Some(on_change) = &on_change {
1110 on_change(state, e);
1111 }
1112 })
1113 .use_24_hour(self.use_24_hour)
1114 .autovalidate(self.autovalidate)
1115 .shared_strings(self.strings.clone()),
1116 )
1117 } else {
1118 let on_change = self.on_change.clone();
1119 any(time_dial(self.value, move |state: &mut State, t| {
1120 if let Some(on_change) = &on_change {
1121 on_change(state, t);
1122 }
1123 })
1124 .use_24_hour(self.use_24_hour)
1125 .shared_strings(self.strings.clone()))
1126 }
1127 }
1128
1129 fn compose(&self) -> OverlayModalView<State> {
1132 let input_mode = self.entry_mode.is_input();
1133 let help = if input_mode {
1134 self.strings.input_help.clone()
1135 } else {
1136 self.strings.dial_help.clone()
1137 };
1138 let confirmed = self.confirmed();
1139 let on_confirm = self.on_confirm.clone();
1140 let mode_button = self.entry_mode.shows_toggle().then(|| {
1141 let requested = self.entry_mode.toggled();
1142 let on_mode_change = self.on_entry_mode_change.clone();
1143 PanelModeButton {
1144 to_input: !input_mode,
1145 tooltip: if input_mode {
1146 self.strings.dial_mode_tooltip.clone()
1147 } else {
1148 self.strings.input_mode_tooltip.clone()
1149 },
1150 on_press: Rc::new(move |state: &mut State| {
1151 if let Some(on_mode_change) = &on_mode_change {
1152 on_mode_change(state, requested);
1153 }
1154 }),
1155 }
1156 });
1157 let panel = TimePickerPanelView {
1158 help,
1159 title: self.value.format(self.use_24_hour, &self.strings),
1160 short_title: self.orientation.is_landscape() && input_mode,
1162 orientation: self.orientation,
1163 input_mode,
1164 mode_button,
1165 body: Rc::new(self.body_view()),
1166 cancel_label: self.strings.cancel.clone(),
1167 confirm_label: self.strings.confirm.clone(),
1168 on_cancel: self.on_cancel.clone(),
1169 on_confirm: Some(Rc::new(move |state: &mut State| {
1170 if let Some(on_confirm) = &on_confirm {
1171 on_confirm(state, confirmed);
1172 }
1173 })),
1174 };
1175 let mut view = overlay_modal(
1176 panel,
1177 OverlayModalConfig::centered(self.orientation.panel_width()),
1178 )
1179 .dismissable(self.dismissable)
1180 .label(if input_mode {
1181 self.strings.input_help.clone()
1182 } else {
1183 self.strings.dial_help.clone()
1184 });
1185 if let Some(on_dismiss) = &self.on_dismiss {
1186 let on_dismiss = on_dismiss.clone();
1187 view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
1188 }
1189 if let Some(dismiss) = &self.dismiss_handle {
1190 view = view.dismiss_handle(dismiss.clone());
1191 }
1192 view
1193 }
1194}
1195
1196impl<State: 'static> View<State> for TimePickerView<State> {
1197 type Element = OverlayModalWidget;
1198
1199 fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
1200 View::build(&self.compose(), ctx)
1201 }
1202
1203 fn rebuild(
1204 &self,
1205 prev: &Self,
1206 element: &mut OverlayModalWidget,
1207 ctx: &mut BuildCtx<'_>,
1208 ) -> ChangeFlags {
1209 View::rebuild(&self.compose(), &prev.compose(), element, ctx)
1210 }
1211
1212 fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
1213 View::teardown(&self.compose(), element, ctx);
1214 }
1215}
1216
1217impl<State: 'static> OverlayModalContent<State> for TimePickerView<State> {
1218 fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
1219 self.on_dismiss = Some(on_dismiss);
1220 self
1221 }
1222
1223 fn modal_dismissable(&self) -> bool {
1224 self.dismissable
1225 }
1226}
1227
1228pub fn show_time_picker<State, B, R>(
1242 controller: &NavigatorController<State>,
1243 build: B,
1244 on_result: R,
1245) where
1246 State: 'static,
1247 B: Fn() -> TimePickerView<State> + 'static,
1248 R: Fn(&mut State, PopResult) + 'static,
1249{
1250 show_overlay_modal(controller, build, on_result);
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255 use super::*;
1256 use frust::authoring::scene::GlyphRun;
1257 use frust::authoring::text::TextContext;
1258 use frust::authoring::{
1259 CornerRadii, Key, KeyEvent, Modifiers, NamedKey, PointerButton, PointerEvent, PointerPhase,
1260 };
1261 use frust::{Color, FrameTime, NavigatorView};
1262 use frust_core::RenderRoot;
1263 use frust_widgets::navigator;
1264 use kurbo::Rect;
1265 use std::any::Any;
1266
1267 #[test]
1268 fn a_time_clamps_both_halves_on_construction() {
1269 assert_eq!(TimeOfDay::new(99, 99), TimeOfDay::new(23, 59));
1270 assert_eq!(TimeOfDay::new(23, 59).hour(), 23);
1271 assert_eq!(TimeOfDay::default(), TimeOfDay::MIDNIGHT);
1272 assert_eq!(TimeOfDay::MIDNIGHT.hour_of_12(), 12, "00:00 reads as 12 AM");
1273 }
1274
1275 #[test]
1276 fn the_twelve_hour_view_of_every_hour_round_trips() {
1277 for hour in 0..24u8 {
1278 let t = TimeOfDay::new(hour, 0);
1279 let back = TimeOfDay::from_hour_12(t.hour_of_12(), t.is_pm(), 0);
1280 assert_eq!(back, t, "hour {hour} must survive the 12-hour round trip");
1281 }
1282 }
1283
1284 #[test]
1285 fn with_period_keeps_the_clock_face() {
1286 let ten_am = TimeOfDay::new(10, 30);
1287 assert_eq!(ten_am.with_period(true), TimeOfDay::new(22, 30));
1288 assert_eq!(ten_am.with_period(false), ten_am, "already AM");
1289 assert_eq!(
1291 TimeOfDay::new(12, 0).with_period(false),
1292 TimeOfDay::new(0, 0)
1293 );
1294 assert_eq!(
1295 TimeOfDay::new(0, 0).with_period(true),
1296 TimeOfDay::new(12, 0)
1297 );
1298 }
1299
1300 #[test]
1301 fn labels_are_zero_padded_per_format() {
1302 let t = TimeOfDay::new(9, 5);
1303 assert_eq!(t.hour_label(true), "09");
1304 assert_eq!(t.hour_label(false), "09");
1305 assert_eq!(TimeOfDay::new(21, 5).hour_label(true), "21");
1306 assert_eq!(TimeOfDay::new(21, 5).hour_label(false), "09");
1307 assert_eq!(t.minute_label(), "05");
1308 }
1309
1310 #[test]
1311 fn the_headline_follows_the_format() {
1312 let strings = TimePickerStrings::default();
1313 assert_eq!(TimeOfDay::new(21, 5).format(true, &strings), "21:05");
1314 assert_eq!(TimeOfDay::new(21, 5).format(false, &strings), "9:05 PM");
1315 assert_eq!(TimeOfDay::new(0, 0).format(false, &strings), "12:00 AM");
1316 assert_eq!(TimeOfDay::new(12, 0).format(false, &strings), "12:00 PM");
1317 }
1318
1319 #[test]
1320 fn the_toggle_is_a_no_op_for_the_fixed_modes() {
1321 assert_eq!(
1322 TimePickerEntryMode::Dial.toggled(),
1323 TimePickerEntryMode::Input
1324 );
1325 assert_eq!(
1326 TimePickerEntryMode::Input.toggled(),
1327 TimePickerEntryMode::Dial
1328 );
1329 for fixed in [
1330 TimePickerEntryMode::DialOnly,
1331 TimePickerEntryMode::InputOnly,
1332 ] {
1333 assert_eq!(fixed.toggled(), fixed);
1334 assert!(!fixed.shows_toggle());
1335 }
1336 assert!(TimePickerEntryMode::Dial.shows_toggle());
1337 assert!(TimePickerEntryMode::Input.shows_toggle());
1338 assert!(TimePickerEntryMode::InputOnly.is_input());
1339 assert!(!TimePickerEntryMode::DialOnly.is_input());
1340 }
1341
1342 #[test]
1343 fn the_orientation_rule_matches_media_query() {
1344 assert_eq!(
1345 TimePickerOrientation::for_size(Size::new(392.0, 800.0)),
1346 TimePickerOrientation::Portrait
1347 );
1348 assert_eq!(
1349 TimePickerOrientation::for_size(Size::new(800.0, 392.0)),
1350 TimePickerOrientation::Landscape
1351 );
1352 assert_eq!(
1354 TimePickerOrientation::for_size(Size::new(600.0, 600.0)),
1355 TimePickerOrientation::Portrait
1356 );
1357 assert_eq!(
1358 TimePickerOrientation::Portrait.panel_width(),
1359 DIALOG_PORTRAIT_WIDTH
1360 );
1361 assert_eq!(
1362 TimePickerOrientation::Landscape.panel_width(),
1363 DIALOG_LANDSCAPE_WIDTH
1364 );
1365 }
1366
1367 #[test]
1368 fn confirm_reports_the_dial_value_in_dial_mode() {
1369 let view: TimePickerView<()> = time_picker(TimeOfDay::new(14, 45));
1370 assert_eq!(view.confirmed(), Some(TimeOfDay::new(14, 45)));
1371 }
1372
1373 #[test]
1374 fn confirm_parses_the_entry_in_input_mode_and_reports_none_when_it_cannot() {
1375 let good: TimePickerView<()> = time_picker(TimeOfDay::MIDNIGHT)
1376 .entry_mode(TimePickerEntryMode::Input)
1377 .entry(TimeEntry {
1378 hour_text: "7".into(),
1379 minute_text: "05".into(),
1380 pm: true,
1381 });
1382 assert_eq!(good.confirmed(), Some(TimeOfDay::new(19, 5)));
1383
1384 let bad: TimePickerView<()> = time_picker(TimeOfDay::MIDNIGHT)
1385 .entry_mode(TimePickerEntryMode::InputOnly)
1386 .entry(TimeEntry {
1387 hour_text: "99".into(),
1388 minute_text: "".into(),
1389 pm: false,
1390 });
1391 assert_eq!(
1392 bad.confirmed(),
1393 None,
1394 "the failed-validation branch reports None instead of a guessed time"
1395 );
1396 }
1397
1398 #[test]
1399 fn an_unset_entry_seeds_itself_from_the_value() {
1400 let view: TimePickerView<()> = time_picker(TimeOfDay::new(21, 5))
1401 .entry_mode(TimePickerEntryMode::Input)
1402 .use_24_hour(true);
1403 assert_eq!(view.resolved_entry().hour_text, "21");
1404 assert_eq!(view.confirmed(), Some(TimeOfDay::new(21, 5)));
1405 }
1406
1407 struct Sized {
1414 size: Size,
1415 }
1416 struct SizedW {
1417 size: Size,
1418 }
1419 impl View<NavState> for Sized {
1420 type Element = SizedW;
1421 fn build(&self, _c: &mut BuildCtx<'_>) -> SizedW {
1422 SizedW { size: self.size }
1423 }
1424 fn rebuild(&self, _p: &Self, _e: &mut SizedW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1425 ChangeFlags::NONE
1426 }
1427 }
1428 impl Widget for SizedW {
1429 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1430 bc.constrain(self.size)
1431 }
1432 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1433 scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1434 }
1435 }
1436
1437 fn panel_view(
1438 orientation: TimePickerOrientation,
1439 input_mode: bool,
1440 ) -> TimePickerPanelView<NavState> {
1441 TimePickerPanelView {
1442 help: "Select time".into(),
1443 title: "10:30 AM".into(),
1444 short_title: false,
1445 orientation,
1446 input_mode,
1447 mode_button: None,
1448 body: Rc::new(any(Sized {
1449 size: Size::new(200.0, 100.0),
1450 })),
1451 cancel_label: "Cancel".into(),
1452 confirm_label: "OK".into(),
1453 on_cancel: None,
1454 on_confirm: None,
1455 }
1456 }
1457
1458 fn mount_panel(
1459 view: &TimePickerPanelView<NavState>,
1460 width: f64,
1461 ) -> (TimePickerPanelWidget, Size, TextContext) {
1462 let mut counter = 0u64;
1463 let mut widget = View::<NavState>::build(view, &mut BuildCtx::new(&mut counter));
1464 let mut tcx = TextContext::new();
1465 let size = {
1466 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1467 widget.layout(
1468 &mut ctx,
1469 &BoxConstraints::new(Size::new(width, 0.0), Size::new(width, f64::INFINITY)),
1470 )
1471 };
1472 (widget, size, tcx)
1473 }
1474
1475 #[test]
1476 fn the_portrait_panel_stacks_header_divider_body_and_actions() {
1477 let view = panel_view(TimePickerOrientation::Portrait, false);
1478 let (widget, size, _tcx) = mount_panel(&view, DIALOG_PORTRAIT_WIDTH);
1479 assert_eq!(size.width, DIALOG_PORTRAIT_WIDTH);
1480 assert_eq!(widget.help.origin().x, PADDING);
1481 assert!(
1482 widget.title.origin().y > widget.help.origin().y,
1483 "title below help"
1484 );
1485 assert!(
1486 widget.divider.origin().y > widget.title.origin().y,
1487 "divider below the header block"
1488 );
1489 assert_eq!(
1490 widget.divider.size().width,
1491 DIALOG_PORTRAIT_WIDTH,
1492 "full bleed"
1493 );
1494 assert!(widget.body.origin().y > widget.divider.origin().y);
1495 assert_eq!(
1496 widget.body.origin().x,
1497 0.0,
1498 "the dial body is not inset — it centres itself"
1499 );
1500 let cancel = widget.actions[0].origin().x;
1502 let confirm = widget.actions[1].origin().x;
1503 assert!(
1504 confirm > cancel,
1505 "confirm sits closest to the trailing edge"
1506 );
1507 assert!(
1508 (confirm + widget.actions[1].size().width - (DIALOG_PORTRAIT_WIDTH - PADDING)).abs()
1509 < 1e-9
1510 );
1511 assert!(widget.actions[0].origin().y > widget.body.origin().y);
1512 }
1513
1514 #[test]
1515 fn the_input_body_is_inset_but_the_dial_body_is_not() {
1516 let dial = panel_view(TimePickerOrientation::Portrait, false);
1517 let (dial_widget, _, _t1) = mount_panel(&dial, DIALOG_PORTRAIT_WIDTH);
1518 assert_eq!(dial_widget.body.origin().x, 0.0);
1519 assert_eq!(dial_widget.body.size().width, DIALOG_PORTRAIT_WIDTH);
1520
1521 let input = panel_view(TimePickerOrientation::Portrait, true);
1522 let (input_widget, _, _t2) = mount_panel(&input, DIALOG_PORTRAIT_WIDTH);
1523 assert_eq!(input_widget.body.origin().x, PADDING);
1524 assert_eq!(
1525 input_widget.body.size().width,
1526 DIALOG_PORTRAIT_WIDTH - 2.0 * PADDING
1527 );
1528 }
1529
1530 #[test]
1531 fn the_landscape_panel_puts_the_header_column_beside_the_body() {
1532 let view = panel_view(TimePickerOrientation::Landscape, false);
1533 let (widget, size, _tcx) = mount_panel(&view, DIALOG_LANDSCAPE_WIDTH);
1534 assert_eq!(size.width, DIALOG_LANDSCAPE_WIDTH);
1535 assert_eq!(widget.help.origin().x, PADDING, "header column is inset");
1536 assert!(widget.help.origin().x < HEADER_LANDSCAPE_WIDTH);
1537 assert_eq!(
1538 widget.divider.origin(),
1539 Point::new(HEADER_LANDSCAPE_WIDTH, 0.0)
1540 );
1541 assert_eq!(
1542 widget.divider.size().width,
1543 DIVIDER_THICKNESS,
1544 "vertical rule"
1545 );
1546 assert_eq!(widget.divider.size().height, size.height, "full height");
1547 assert!(
1548 widget.body.origin().x >= HEADER_LANDSCAPE_WIDTH + DIVIDER_THICKNESS,
1549 "the body sits right of the divider"
1550 );
1551 assert!(
1552 widget.actions[0].origin().y > widget.body.origin().y,
1553 "actions sit under the body, inside the right-hand column"
1554 );
1555 assert!(widget.actions[0].origin().x > HEADER_LANDSCAPE_WIDTH);
1556 }
1557
1558 const WINDOW: Size = Size::new(420.0, 900.0);
1565
1566 #[derive(Default)]
1567 struct NavState {
1568 results: Vec<Option<TimeOfDay>>,
1569 confirmed: Vec<Option<TimeOfDay>>,
1570 cancels: u32,
1571 value: TimeOfDay,
1572 }
1573
1574 #[derive(Default)]
1575 struct Recorder {
1576 glyphs: usize,
1577 }
1578 impl PaintScene for Recorder {
1579 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1580 fn draw_text(&mut self, _o: Point, _t: &str) {}
1581 fn draw_glyph_run(&mut self, _run: GlyphRun) {
1582 self.glyphs += 1;
1583 }
1584 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
1585 fn fill_rounded_rect_radii(&mut self, _o: Point, _s: Size, _radii: CornerRadii, _c: Color) {
1586 }
1587 }
1588
1589 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1590 InputEvent::Pointer(PointerEvent {
1591 phase,
1592 position: Point::new(x, y),
1593 button: PointerButton::Primary,
1594 })
1595 }
1596
1597 fn escape_event() -> InputEvent {
1598 InputEvent::Key(KeyEvent {
1599 key: Key::Named(NamedKey::Escape),
1600 modifiers: Modifiers::default(),
1601 repeat: false,
1602 })
1603 }
1604
1605 type NavLogic = Box<dyn FnMut(&mut NavState) -> NavigatorView<NavState>>;
1606
1607 struct Harness {
1608 root: RenderRoot<NavState, NavigatorView<NavState>>,
1609 state: NavState,
1610 tcx: TextContext,
1611 controller: NavigatorController<NavState>,
1612 logic: NavLogic,
1613 }
1614
1615 impl Harness {
1616 fn new() -> Self {
1617 let controller: NavigatorController<NavState> = NavigatorController::new();
1618 let ctrl = controller.clone();
1619 let mut h = Harness {
1620 root: RenderRoot::new(),
1621 state: NavState::default(),
1622 tcx: TextContext::new(),
1623 controller,
1624 logic: Box::new(move |_: &mut NavState| {
1625 navigator(&ctrl, || {
1626 any::<NavState, _>(Sized {
1627 size: Size::new(WINDOW.width, WINDOW.height),
1628 })
1629 })
1630 }),
1631 };
1632 h.pass();
1633 h
1634 }
1635
1636 fn pass(&mut self) {
1637 self.root.rebuild(&mut self.logic, &mut self.state);
1638 self.root
1639 .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
1640 }
1641
1642 fn settle(&mut self) {
1644 self.root.paint(&mut Recorder::default(), FrameTime::ZERO);
1645 self.root.paint(
1646 &mut Recorder::default(),
1647 FrameTime::from_nanos(1_000_000_000),
1648 );
1649 self.pass();
1650 }
1651
1652 fn settle_exit(&mut self) {
1654 self.root.paint(
1655 &mut Recorder::default(),
1656 FrameTime::from_nanos(2_000_000_000),
1657 );
1658 self.root.paint(
1659 &mut Recorder::default(),
1660 FrameTime::from_nanos(3_000_000_000),
1661 );
1662 self.pass();
1663 }
1664
1665 fn event(&mut self, event: &InputEvent) {
1666 self.root.event(&mut self.state, event);
1667 }
1668
1669 fn flush(&mut self) {
1670 self.pass();
1671 self.event(&ev(PointerPhase::Move, 2.0, 2.0));
1672 }
1673
1674 fn paint(&mut self) -> Recorder {
1675 let mut rec = Recorder::default();
1676 self.root
1677 .paint(&mut rec, FrameTime::from_nanos(4_000_000_000));
1678 rec
1679 }
1680
1681 fn action_bounds(&self) -> Vec<Rect> {
1685 let mut found: Vec<Rect> = self
1686 .root
1687 .inspect()
1688 .into_iter()
1689 .filter(|n| n.type_name.contains("::ButtonWidget"))
1690 .map(|n| n.bounds)
1691 .collect();
1692 found.sort_by(|a, b| a.x0.partial_cmp(&b.x0).expect("finite bounds"));
1693 found
1694 }
1695
1696 fn tap(&mut self, rect: Rect) {
1697 let c = rect.center();
1698 self.event(&ev(PointerPhase::Down, c.x, c.y));
1699 self.event(&ev(PointerPhase::Up, c.x, c.y));
1700 self.flush();
1701 }
1702 }
1703
1704 fn show_dial_picker(h: &mut Harness) {
1705 let ctrl = h.controller.clone();
1706 show_time_picker(
1707 &h.controller,
1708 move || {
1709 let ctrl = ctrl.clone();
1710 let cancel_ctrl = ctrl.clone();
1711 time_picker(TimeOfDay::new(14, 45))
1712 .on_confirm(move |state: &mut NavState, confirmed| {
1713 state.confirmed.push(confirmed);
1714 if let Some(time) = confirmed {
1715 ctrl.pop_with_result(PopResult::of(time));
1716 }
1717 })
1718 .on_cancel(move |state: &mut NavState| {
1719 state.cancels += 1;
1720 cancel_ctrl.pop();
1721 })
1722 },
1723 |state: &mut NavState, result: PopResult| {
1724 state.results.push(result.take::<TimeOfDay>());
1725 },
1726 );
1727 h.pass();
1728 h.settle();
1729 }
1730
1731 #[test]
1732 fn the_picker_renders_its_header_body_and_actions() {
1733 let mut h = Harness::new();
1734 show_dial_picker(&mut h);
1735 let rec = h.paint();
1736 assert!(
1739 rec.glyphs >= 15,
1740 "the whole picker shapes, got {} runs",
1741 rec.glyphs
1742 );
1743 }
1744
1745 #[test]
1746 fn confirming_pops_the_chosen_time_through_to_the_result_callback() {
1747 let mut h = Harness::new();
1748 show_dial_picker(&mut h);
1749 let actions = h.action_bounds();
1750 assert_eq!(actions.len(), 2, "cancel and confirm");
1751 h.tap(actions[1]);
1753 h.settle_exit();
1754 assert_eq!(
1755 h.state.confirmed,
1756 vec![Some(TimeOfDay::new(14, 45))],
1757 "the dial's value is what confirm reports"
1758 );
1759 assert_eq!(
1760 h.state.results,
1761 vec![Some(TimeOfDay::new(14, 45))],
1762 "and it reaches the result callback through the pop"
1763 );
1764 }
1765
1766 #[test]
1767 fn cancelling_pops_with_no_result() {
1768 let mut h = Harness::new();
1769 show_dial_picker(&mut h);
1770 let actions = h.action_bounds();
1771 h.tap(actions[0]);
1772 h.settle_exit();
1773 assert_eq!(h.state.cancels, 1);
1774 assert_eq!(h.state.confirmed, Vec::new(), "confirm never fired");
1775 assert_eq!(h.state.results, vec![None], "an empty pop result");
1776 }
1777
1778 #[test]
1779 fn escape_dismisses_the_picker_with_an_empty_result() {
1780 let mut h = Harness::new();
1781 show_dial_picker(&mut h);
1782 h.event(&ev(PointerPhase::Down, 200.0, 200.0));
1785 h.event(&ev(PointerPhase::Up, 200.0, 200.0));
1786 h.flush();
1787 assert!(h.state.results.is_empty());
1788 h.event(&escape_event());
1789 h.flush();
1790 assert!(h.state.results.is_empty(), "still animating out");
1791 h.settle_exit();
1792 h.flush();
1793 assert_eq!(h.state.results, vec![None]);
1794 assert_eq!(h.state.confirmed, Vec::new());
1795 }
1796
1797 #[test]
1798 fn an_invalid_entry_refuses_to_confirm_and_leaves_the_dialog_up() {
1799 let mut h = Harness::new();
1800 let ctrl = h.controller.clone();
1801 show_time_picker(
1802 &h.controller,
1803 move || {
1804 let ctrl = ctrl.clone();
1805 time_picker(TimeOfDay::MIDNIGHT)
1806 .entry_mode(TimePickerEntryMode::InputOnly)
1807 .entry(TimeEntry {
1808 hour_text: "99".into(),
1809 minute_text: String::new(),
1810 pm: false,
1811 })
1812 .on_confirm(move |state: &mut NavState, confirmed| {
1813 state.confirmed.push(confirmed);
1814 if let Some(time) = confirmed {
1815 ctrl.pop_with_result(PopResult::of(time));
1816 }
1817 })
1818 },
1819 |state: &mut NavState, result: PopResult| {
1820 state.results.push(result.take::<TimeOfDay>());
1821 },
1822 );
1823 h.pass();
1824 h.settle();
1825 let actions = h.action_bounds();
1826 let confirm = *actions.last().expect("a confirm action");
1829 h.tap(confirm);
1830 h.settle_exit();
1831 assert_eq!(
1832 h.state.confirmed,
1833 vec![None],
1834 "the failed-validation branch fires with None"
1835 );
1836 assert!(h.state.results.is_empty(), "and nothing pops");
1837 }
1838
1839 #[test]
1840 fn the_mode_toggle_reports_the_other_entry_mode() {
1841 let mut h = Harness::new();
1842 let requested: Rc<std::cell::RefCell<Vec<TimePickerEntryMode>>> = Rc::default();
1843 let sink = requested.clone();
1844 show_time_picker(
1845 &h.controller,
1846 move || {
1847 let sink = sink.clone();
1848 time_picker(TimeOfDay::new(9, 0)).on_entry_mode_change(
1849 move |_state: &mut NavState, mode| sink.borrow_mut().push(mode),
1850 )
1851 },
1852 |_state: &mut NavState, _result: PopResult| {},
1853 );
1854 h.pass();
1855 h.settle();
1856 let toggle = h
1857 .root
1858 .inspect()
1859 .into_iter()
1860 .find(|n| n.type_name.contains("::IconButtonWidget"))
1861 .expect("the dial mode offers a toggle")
1862 .bounds;
1863 h.tap(toggle);
1864 assert_eq!(
1865 requested.borrow().as_slice(),
1866 &[TimePickerEntryMode::Input],
1867 "the dial's toggle asks for text entry"
1868 );
1869 }
1870
1871 #[test]
1872 fn a_dial_edit_reports_the_requested_time_out_of_the_dialog() {
1873 let mut h = Harness::new();
1874 let ctrl = h.controller.clone();
1875 show_time_picker(
1876 &h.controller,
1877 move || {
1878 let _ = &ctrl;
1879 time_picker(TimeOfDay::new(14, 45))
1880 .on_change(|state: &mut NavState, time| state.value = time)
1881 },
1882 |_state: &mut NavState, _result: PopResult| {},
1883 );
1884 h.pass();
1885 h.settle();
1886 let ring = h
1889 .root
1890 .inspect()
1891 .into_iter()
1892 .find(|n| n.type_name.contains("::TimeDialWidget"))
1893 .expect("the dial is mounted")
1894 .bounds;
1895 let ring_center = Point::new(ring.center().x, ring.y1 - DIAL_SIZE / 2.0);
1897 let top = Point::new(ring_center.x, ring_center.y - DIAL_SIZE / 2.0 + 30.0);
1898 h.event(&ev(PointerPhase::Down, top.x, top.y));
1899 h.event(&ev(PointerPhase::Up, top.x, top.y));
1900 h.flush();
1901 assert_eq!(
1902 h.state.value,
1903 TimeOfDay::new(12, 45),
1904 "12 o'clock in the PM half is 12:45"
1905 );
1906 }
1907
1908 #[test]
1909 fn an_installed_dismiss_handle_reaches_the_host_view() {
1910 let dismiss = ModalDismiss::new();
1914 assert!(
1915 time_picker::<NavState>(TimeOfDay::new(9, 30))
1916 .dismiss_handle(dismiss)
1917 .compose()
1918 .dismiss_handle
1919 .is_some()
1920 );
1921 assert!(
1922 time_picker::<NavState>(TimeOfDay::new(9, 30))
1923 .compose()
1924 .dismiss_handle
1925 .is_none(),
1926 "and none is invented for a picker that was handed none"
1927 );
1928 }
1929}