1use std::rc::Rc;
82
83use frust::authoring::text::{FontFamily, FontWeight, TextContext, TextLayout, TextStyle};
84use frust::authoring::{
85 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
86 LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any, build_child, rebuild_child,
87 rebuild_children, route_event, route_event_single, teardown_child, visit_children,
88};
89use frust::{Color, NavigatorController, PopResult, Theme, icon};
90use kurbo::{Point, Size};
91use peniko::Brush;
92
93use super::calendar::{SelectableDay, calendar_date_picker};
94use super::date::MaterialDate;
95use super::input::date_input_field;
96use super::{
97 ACTIONS_MIN_HEIGHT, DIALOG_PORTRAIT_CALENDAR_WIDTH, DIALOG_PORTRAIT_INPUT_WIDTH,
98 DatePickerState, DatePickerStrings, HEADER_PORTRAIT_HEIGHT, OnDatePickerChange,
99};
100use crate::button::{button, text_button};
101use crate::divider::divider;
102use crate::icon_button::icon_button;
103use crate::overlay::{
104 ModalDismiss, OverlayModalConfig, OverlayModalContent, OverlayModalView, OverlayModalWidget,
105 overlay_modal, show_overlay_modal,
106};
107
108const PANEL_PADDING: f64 = 24.0;
111const ACTION_GAP: f64 = 8.0;
113const TOGGLE_SLOT: f64 = 48.0;
115const HEADLINE_GAP: f64 = 4.0;
117
118const HELP_TEXT_SIZE: f32 = 14.0;
120const HEADLINE_TEXT_SIZE: f32 = 32.0;
122const SHAPING_INK: Color = Color::BLACK;
124
125const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
127const FALLBACK_ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
129
130type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
132type OnConfirm<State> = Rc<dyn Fn(&mut State, Option<MaterialDate>)>;
134
135fn confirm_action<State: 'static>(
141 on_confirm: Option<&OnConfirm<State>>,
142 can_confirm: bool,
143 confirmable: Option<MaterialDate>,
144 app: &mut State,
145) {
146 if !can_confirm {
147 return;
148 }
149 if let Some(on_confirm) = on_confirm {
150 on_confirm(app, confirmable);
151 }
152}
153
154fn cancel_action<State: 'static>(
171 dismiss: Option<&ModalDismiss>,
172 on_dismiss: Option<&OnDismiss<State>>,
173 app: &mut State,
174) {
175 if let Some(dismiss) = dismiss {
176 dismiss.dismiss();
177 return;
178 }
179 if let Some(on_dismiss) = on_dismiss {
180 on_dismiss(app);
181 }
182}
183
184pub struct DatePickerDialog<State: 'static> {
186 state: DatePickerState,
187 first_date: MaterialDate,
188 last_date: MaterialDate,
189 today: Option<MaterialDate>,
190 strings: DatePickerStrings,
191 selectable: Option<SelectableDay>,
192 on_change: OnDatePickerChange<State>,
193 on_confirm: Option<OnConfirm<State>>,
194 dismissable: bool,
195 on_dismiss: Option<OnDismiss<State>>,
196 dismiss_handle: Option<ModalDismiss>,
197}
198
199pub fn date_picker_dialog<State: 'static, F>(
222 state: DatePickerState,
223 first_date: MaterialDate,
224 last_date: MaterialDate,
225 on_change: F,
226) -> DatePickerDialog<State>
227where
228 F: Fn(&mut State, DatePickerState) + 'static,
229{
230 let (first_date, last_date) = if last_date < first_date {
231 (last_date, first_date)
232 } else {
233 (first_date, last_date)
234 };
235 DatePickerDialog {
236 state,
237 first_date,
238 last_date,
239 today: None,
240 strings: DatePickerStrings::ENGLISH,
241 selectable: None,
242 on_change: Rc::new(on_change),
243 on_confirm: None,
244 dismissable: true,
245 on_dismiss: None,
246 dismiss_handle: None,
247 }
248}
249
250impl<State: 'static> DatePickerDialog<State> {
251 pub fn today(mut self, today: MaterialDate) -> Self {
255 self.today = Some(today);
256 self
257 }
258
259 pub fn strings(mut self, strings: DatePickerStrings) -> Self {
261 self.strings = strings;
262 self
263 }
264
265 pub fn selectable<F: Fn(MaterialDate) -> bool + 'static>(mut self, predicate: F) -> Self {
268 self.selectable = Some(Rc::new(predicate));
269 self
270 }
271
272 pub fn on_confirm<F: Fn(&mut State, Option<MaterialDate>) + 'static>(
277 mut self,
278 on_confirm: F,
279 ) -> Self {
280 self.on_confirm = Some(Rc::new(on_confirm));
281 self
282 }
283
284 pub fn dismissable(mut self, dismissable: bool) -> Self {
288 self.dismissable = dismissable;
289 self
290 }
291
292 pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
298 self.on_dismiss = Some(Rc::new(on_dismiss));
299 self
300 }
301
302 pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
308 self.dismiss_handle = Some(dismiss);
309 self
310 }
311
312 fn is_selectable(&self, date: MaterialDate) -> bool {
314 date.is_within(self.first_date, self.last_date)
315 && self.selectable.as_ref().is_none_or(|p| p(date))
316 }
317
318 pub fn confirmable(&self) -> Option<MaterialDate> {
326 if self.state.entry_mode.is_input() {
327 MaterialDate::parse_compact(&self.state.input_text)
328 .filter(|date| self.is_selectable(*date))
329 } else {
330 self.state.selected.filter(|date| self.is_selectable(*date))
331 }
332 }
333
334 pub fn can_confirm(&self) -> bool {
337 !self.state.entry_mode.is_input() || self.confirmable().is_some()
338 }
339
340 fn panel_width(&self) -> f64 {
342 if self.state.entry_mode.is_input() {
343 DIALOG_PORTRAIT_INPUT_WIDTH
344 } else {
345 DIALOG_PORTRAIT_CALENDAR_WIDTH
346 }
347 }
348
349 fn headline(&self) -> String {
357 match self.state.selected {
358 Some(date) => self.strings.medium_date(date),
359 None => self.strings.no_date_selected.to_string(),
360 }
361 }
362
363 fn toggle_view(&self) -> Option<AnyView<State>> {
365 if !self.state.entry_mode.is_toggleable() {
366 return None;
367 }
368 let glyph = if self.state.entry_mode.is_input() {
369 crate::icons::CALENDAR_TODAY
370 } else {
371 crate::icons::EDIT_OUTLINED
372 };
373 let state = self.state.clone();
374 let on_change = self.on_change.clone();
375 Some(any::<State, _>(icon_button(
376 any::<State, _>(icon(glyph)),
377 move |app: &mut State| on_change(app, state.toggled_entry_mode()),
378 )))
379 }
380
381 fn body_view(&self) -> AnyView<State> {
383 let on_change = self.on_change.clone();
384 if self.state.entry_mode.is_input() {
385 let mut field = date_input_field(
386 self.state.clone(),
387 self.first_date,
388 self.last_date,
389 move |app: &mut State, next| on_change(app, next),
390 )
391 .strings(self.strings);
392 if let Some(predicate) = &self.selectable {
393 let predicate = predicate.clone();
394 field = field.selectable(move |date| predicate(date));
395 }
396 any::<State, _>(field)
397 } else {
398 let mut calendar = calendar_date_picker(
399 self.state.clone(),
400 self.first_date,
401 self.last_date,
402 move |app: &mut State, next| on_change(app, next),
403 )
404 .strings(self.strings);
405 if let Some(today) = self.today {
406 calendar = calendar.today(today);
407 }
408 if let Some(predicate) = &self.selectable {
409 let predicate = predicate.clone();
410 calendar = calendar.selectable(move |date| predicate(date));
411 }
412 any::<State, _>(calendar)
413 }
414 }
415
416 fn cancel_view(&self) -> AnyView<State> {
419 let on_dismiss = self.on_dismiss.clone();
420 let dismiss = self.dismiss_handle.clone();
421 any::<State, _>(text_button(
422 self.strings.cancel_label,
423 move |app: &mut State| cancel_action(dismiss.as_ref(), on_dismiss.as_ref(), app),
424 ))
425 }
426
427 fn confirm_view(&self) -> AnyView<State> {
430 let on_confirm = self.on_confirm.clone();
431 let confirmable = self.confirmable();
432 let can_confirm = self.can_confirm();
433 any::<State, _>(button(
434 self.strings.confirm_label,
435 move |app: &mut State| {
436 confirm_action(on_confirm.as_ref(), can_confirm, confirmable, app)
437 },
438 ))
439 }
440
441 pub fn confirm(&self, app: &mut State) {
446 confirm_action(
447 self.on_confirm.as_ref(),
448 self.can_confirm(),
449 self.confirmable(),
450 app,
451 );
452 }
453
454 pub fn cancel(&self, app: &mut State) {
456 cancel_action(self.dismiss_handle.as_ref(), self.on_dismiss.as_ref(), app);
457 }
458
459 fn compose(&self) -> OverlayModalView<State> {
462 let panel = DatePickerPanel {
463 help: self.strings.help_text.to_string(),
464 headline: self.headline(),
465 toggle: self.toggle_view(),
466 body: self.body_view(),
467 body_inset: if self.state.entry_mode.is_input() {
468 PANEL_PADDING
469 } else {
470 0.0
471 },
472 cancel: self.cancel_view(),
473 confirm: self.confirm_view(),
474 };
475 let mut view = overlay_modal(panel, OverlayModalConfig::centered(self.panel_width()))
476 .dismissable(self.dismissable)
477 .label(self.strings.help_text);
478 if let Some(on_dismiss) = &self.on_dismiss {
479 let on_dismiss = on_dismiss.clone();
480 view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
481 }
482 if let Some(dismiss) = &self.dismiss_handle {
483 view = view.dismiss_handle(dismiss.clone());
484 }
485 view
486 }
487}
488
489impl<State: 'static> View<State> for DatePickerDialog<State> {
490 type Element = OverlayModalWidget;
491
492 fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
493 View::build(&self.compose(), ctx)
494 }
495
496 fn rebuild(
497 &self,
498 prev: &Self,
499 element: &mut OverlayModalWidget,
500 ctx: &mut BuildCtx<'_>,
501 ) -> ChangeFlags {
502 View::rebuild(&self.compose(), &prev.compose(), element, ctx)
503 }
504
505 fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
506 View::teardown(&self.compose(), element, ctx);
507 }
508}
509
510impl<State: 'static> OverlayModalContent<State> for DatePickerDialog<State> {
511 fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
512 self.on_dismiss = Some(on_dismiss);
513 self
514 }
515
516 fn modal_dismissable(&self) -> bool {
517 self.dismissable
518 }
519}
520
521pub fn show_date_picker<State, B, R>(
535 controller: &NavigatorController<State>,
536 build: B,
537 on_result: R,
538) where
539 State: 'static,
540 B: Fn() -> DatePickerDialog<State> + 'static,
541 R: Fn(&mut State, PopResult) + 'static,
542{
543 let dismiss = ModalDismiss::new();
544 show_overlay_modal(
545 controller,
546 move || {
547 let confirm_dismiss = dismiss.clone();
548 build()
549 .on_confirm(
550 move |_state: &mut State, picked: Option<MaterialDate>| match picked {
551 Some(date) => confirm_dismiss.dismiss_with(PopResult::of(date)),
552 None => confirm_dismiss.dismiss(),
553 },
554 )
555 .dismiss_handle(dismiss.clone())
556 },
557 on_result,
558 );
559}
560
561struct DatePickerPanel<State: 'static> {
567 help: String,
568 headline: String,
569 toggle: Option<AnyView<State>>,
570 body: AnyView<State>,
571 body_inset: f64,
572 cancel: AnyView<State>,
573 confirm: AnyView<State>,
574}
575
576struct HeaderRun {
580 content: String,
581 size: f32,
582 weight: FontWeight,
583 family: FontFamily,
585 layout: Option<TextLayout>,
586}
587
588impl HeaderRun {
589 fn new(size: f32, weight: FontWeight) -> Self {
590 Self {
591 content: String::new(),
592 size,
593 weight,
594 family: FontFamily::default(),
595 layout: None,
596 }
597 }
598
599 fn style(&self) -> TextStyle {
601 TextStyle {
602 family: self.family.clone(),
603 weight: self.weight,
604 ..TextStyle::new(self.size, SHAPING_INK)
605 }
606 }
607
608 fn set_content(&mut self, content: &str) {
609 if self.content != content {
610 self.content = content.to_string();
611 self.layout = None;
612 }
613 }
614
615 fn shape(&mut self, ctx: &mut LayoutCtx, family: &FontFamily, max_width: f64) -> Size {
626 if self.family != *family {
627 self.family = family.clone();
628 self.layout = None;
629 }
630 if let Some(layout) = &self.layout {
631 return layout.size();
632 }
633 let style = self.style();
634 let laid = ctx.text_context::<TextContext>().layout(
635 &self.content,
636 &style,
637 Some(max_width.max(0.0) as f32),
638 );
639 let size = laid.size();
640 self.layout = Some(laid);
641 size
642 }
643
644 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
645 let Some(layout) = &self.layout else {
646 return;
647 };
648 for mut run in layout.to_scene_runs(origin) {
649 run.brush = Brush::Solid(color);
650 scene.draw_glyph_run(run);
651 }
652 }
653}
654
655struct DatePickerPanelWidget {
657 help: HeaderRun,
658 headline: HeaderRun,
659 toggle: Option<ChildPod>,
660 divider_pod: ChildPod,
661 body: ChildPod,
662 body_inset: f64,
663 actions: Vec<ChildPod>,
665 header_height: f64,
667 help_origin: Point,
668 headline_origin: Point,
669}
670
671fn reconcile_optional<State: 'static>(
674 prev: Option<&AnyView<State>>,
675 next: Option<&AnyView<State>>,
676 pod: &mut Option<ChildPod>,
677 ctx: &mut BuildCtx<'_>,
678) -> ChangeFlags {
679 match (prev, next) {
680 (None, Some(view)) => {
681 *pod = Some(build_child(view, ctx));
682 ChangeFlags::LAYOUT | ChangeFlags::PAINT
683 }
684 (Some(view), None) => {
685 if let Some(p) = pod.as_mut() {
686 teardown_child(view, p, ctx);
687 }
688 *pod = None;
689 ChangeFlags::LAYOUT | ChangeFlags::PAINT
690 }
691 (Some(a), Some(b)) => match pod.as_mut() {
692 Some(p) => rebuild_child(a, b, p, ctx),
693 None => ChangeFlags::NONE,
694 },
695 (None, None) => ChangeFlags::NONE,
696 }
697}
698
699impl<State: 'static> View<State> for DatePickerPanel<State> {
700 type Element = DatePickerPanelWidget;
701
702 fn build(&self, ctx: &mut BuildCtx<'_>) -> DatePickerPanelWidget {
703 let mut help = HeaderRun::new(HELP_TEXT_SIZE, FontWeight::MEDIUM);
704 help.set_content(&self.help);
705 let mut headline = HeaderRun::new(HEADLINE_TEXT_SIZE, FontWeight::REGULAR);
706 headline.set_content(&self.headline);
707 DatePickerPanelWidget {
708 help,
709 headline,
710 toggle: self.toggle.as_ref().map(|v| build_child(v, ctx)),
711 divider_pod: build_child(&any::<State, _>(divider()), ctx),
712 body: build_child(&self.body, ctx),
713 body_inset: self.body_inset,
714 actions: vec![
715 build_child(&self.cancel, ctx),
716 build_child(&self.confirm, ctx),
717 ],
718 header_height: 0.0,
719 help_origin: Point::ZERO,
720 headline_origin: Point::ZERO,
721 }
722 }
723
724 fn rebuild(
725 &self,
726 prev: &Self,
727 element: &mut DatePickerPanelWidget,
728 ctx: &mut BuildCtx<'_>,
729 ) -> ChangeFlags {
730 let mut flags = ChangeFlags::NONE;
731 if prev.help != self.help {
732 element.help.set_content(&self.help);
733 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
734 }
735 if prev.headline != self.headline {
736 element.headline.set_content(&self.headline);
737 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
738 }
739 if element.body_inset != self.body_inset {
740 element.body_inset = self.body_inset;
741 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
742 }
743 flags |= reconcile_optional(
744 prev.toggle.as_ref(),
745 self.toggle.as_ref(),
746 &mut element.toggle,
747 ctx,
748 );
749 flags |= rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
750 flags |= rebuild_child(
751 &any::<State, _>(divider()),
752 &any::<State, _>(divider()),
753 &mut element.divider_pod,
754 ctx,
755 );
756 let prev_actions = [&prev.cancel, &prev.confirm];
757 let next_actions = [&self.cancel, &self.confirm];
758 flags |= rebuild_children(
759 &prev_actions,
760 &next_actions,
761 &mut element.actions,
762 ctx,
763 |view: &&AnyView<State>| *view,
764 |_| None,
765 );
766 flags
767 }
768
769 fn teardown(&self, element: &mut DatePickerPanelWidget, ctx: &mut BuildCtx<'_>) {
770 if let (Some(view), Some(pod)) = (&self.toggle, element.toggle.as_mut()) {
771 teardown_child(view, pod, ctx);
772 }
773 teardown_child(&any::<State, _>(divider()), &mut element.divider_pod, ctx);
774 teardown_child(&self.body, &mut element.body, ctx);
775 for (view, pod) in [&self.cancel, &self.confirm]
776 .into_iter()
777 .zip(element.actions.iter_mut())
778 {
779 teardown_child(view, pod, ctx);
780 }
781 }
782}
783
784impl Widget for DatePickerPanelWidget {
785 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
786 let offered = bc.max().width.max(bc.min().width);
789 let width = if offered.is_finite() {
790 offered
791 } else {
792 DIALOG_PORTRAIT_CALENDAR_WIDTH
793 };
794 let inner_w = (width - 2.0 * PANEL_PADDING).max(0.0);
795
796 let toggle_size = match self.toggle.as_mut() {
798 Some(pod) => pod.layout_child(
799 ctx,
800 &BoxConstraints::loose(Size::new(TOGGLE_SLOT, TOGGLE_SLOT)),
801 ),
802 None => Size::ZERO,
803 };
804 let help_w = (inner_w
805 - if self.toggle.is_some() {
806 TOGGLE_SLOT
807 } else {
808 0.0
809 })
810 .max(0.0);
811 let (help_family, headline_family) = match Theme::from_layout_ctx(ctx) {
814 Some(theme) => (
815 theme.type_scale.label_large.family.clone(),
816 theme.type_scale.headline_large.family.clone(),
817 ),
818 None => (FontFamily::default(), FontFamily::default()),
819 };
820 let help_size = self.help.shape(ctx, &help_family, help_w);
821 let headline_size = self.headline.shape(ctx, &headline_family, inner_w);
822
823 let help_row_h = help_size.height.max(toggle_size.height);
824 let natural =
825 PANEL_PADDING + help_row_h + HEADLINE_GAP + headline_size.height + PANEL_PADDING;
826 self.header_height = natural.max(HEADER_PORTRAIT_HEIGHT);
827 self.help_origin = Point::new(
828 PANEL_PADDING,
829 PANEL_PADDING + (help_row_h - help_size.height) / 2.0,
830 );
831 self.headline_origin = Point::new(
832 PANEL_PADDING,
833 self.header_height - PANEL_PADDING - headline_size.height,
834 );
835 if let Some(pod) = self.toggle.as_mut() {
836 pod.set_origin(Point::new(
837 width - PANEL_PADDING - toggle_size.width,
838 PANEL_PADDING + (help_row_h - toggle_size.height) / 2.0,
839 ));
840 }
841
842 let mut y = self.header_height;
843
844 let divider_size = self
846 .divider_pod
847 .layout_child(ctx, &BoxConstraints::tight(Size::new(width, 1.0)));
848 self.divider_pod.set_origin(Point::new(0.0, y));
849 y += divider_size.height;
850
851 let body_w = (width - 2.0 * self.body_inset).max(0.0);
853 let body_size = self.body.layout_child(
854 ctx,
855 &BoxConstraints::new(Size::new(body_w, 0.0), Size::new(body_w, f64::INFINITY)),
856 );
857 self.body
858 .set_origin(Point::new(self.body_inset, y + self.body_inset));
859 y += body_size.height + 2.0 * self.body_inset;
860
861 let loose = BoxConstraints::loose(Size::new(inner_w, f64::INFINITY));
863 let mut sizes = Vec::with_capacity(self.actions.len());
864 for pod in &mut self.actions {
865 sizes.push(pod.layout_child(ctx, &loose));
866 }
867 let row_h = sizes.iter().fold(0.0_f64, |acc, s| acc.max(s.height));
868 let band = (row_h + PANEL_PADDING).max(ACTIONS_MIN_HEIGHT);
869 let total_w = sizes.iter().map(|s| s.width).sum::<f64>()
870 + ACTION_GAP * (sizes.len().saturating_sub(1)) as f64;
871 let mut x = width - PANEL_PADDING - total_w;
872 for (pod, size) in self.actions.iter_mut().zip(sizes.iter()) {
873 pod.set_origin(Point::new(x, y + (row_h - size.height) / 2.0));
874 x += size.width + ACTION_GAP;
875 }
876 y += band;
877
878 bc.constrain(Size::new(width, y))
879 }
880
881 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
882 let (on_surface, on_surface_variant) = {
883 let theme = Theme::from_paint_ctx(ctx);
884 (
885 theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface),
886 theme.map_or(FALLBACK_ON_SURFACE_VARIANT, |t| {
887 t.scheme().on_surface_variant
888 }),
889 )
890 };
891 let origin = ctx.origin();
892 self.help.paint(
893 Point::new(origin.x + self.help_origin.x, origin.y + self.help_origin.y),
894 on_surface_variant,
895 scene,
896 );
897 self.headline.paint(
898 Point::new(
899 origin.x + self.headline_origin.x,
900 origin.y + self.headline_origin.y,
901 ),
902 on_surface,
903 scene,
904 );
905 if let Some(pod) = self.toggle.as_mut() {
906 pod.paint_child(ctx, scene);
907 }
908 self.divider_pod.paint_child(ctx, scene);
909 self.body.paint_child(ctx, scene);
910 for pod in &mut self.actions {
911 pod.paint_child(ctx, scene);
912 }
913 }
914
915 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
916 if let Some(toggle) = self.toggle.as_mut()
917 && route_event_single(toggle, ctx, event) == EventResult::Handled
918 {
919 return EventResult::Handled;
920 }
921 if route_event_single(&mut self.body, ctx, event) == EventResult::Handled {
922 return EventResult::Handled;
923 }
924 if route_event(&mut self.actions, ctx, event) == EventResult::Handled {
925 return EventResult::Handled;
926 }
927 EventResult::Ignored
928 }
929
930 fn semantics(&self, ctx: &mut SemanticsCtx) {
931 if let Some(pod) = &self.toggle {
932 pod.semantics_child(ctx);
933 }
934 self.body.semantics_child(ctx);
935 for pod in &self.actions {
936 pod.semantics_child(ctx);
937 }
938 }
939
940 visit_children!(toggle, divider_pod, body, actions);
941}
942
943#[cfg(test)]
944mod tests {
945 use std::any::Any;
946 use std::cell::Cell;
947
948 use frust::FrameTime;
949 use frust::authoring::{PointerButton, PointerEvent, PointerPhase};
950 use frust_widgets::test_support::leaf_any;
951
952 use super::super::{DatePickerEntryMode, DatePickerMode};
953 use super::*;
954
955 fn d(year: i32, month: u32, day: u32) -> MaterialDate {
956 MaterialDate::new(year, month, day)
957 }
958
959 const FIRST: fn() -> MaterialDate = || MaterialDate::new(2020, 1, 1);
960 const LAST: fn() -> MaterialDate = || MaterialDate::new(2030, 12, 31);
961
962 #[derive(Default)]
963 struct App {
964 reported: Vec<DatePickerState>,
965 confirmed: Vec<Option<MaterialDate>>,
966 dismissed: usize,
967 }
968
969 fn dialog(state: DatePickerState) -> DatePickerDialog<App> {
970 date_picker_dialog(state, FIRST(), LAST(), |app: &mut App, next| {
971 app.reported.push(next)
972 })
973 .on_confirm(|app: &mut App, picked| app.confirmed.push(picked))
974 .on_dismiss(|app: &mut App| app.dismissed += 1)
975 }
976
977 fn seeded() -> DatePickerState {
978 DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20))
979 }
980
981 #[test]
984 fn calendar_mode_confirms_whatever_is_selected() {
985 assert_eq!(dialog(seeded()).confirmable(), Some(d(2026, 8, 20)));
986 assert!(dialog(seeded()).can_confirm());
987
988 let empty = DatePickerState::new(None, d(2026, 8, 20));
990 assert_eq!(dialog(empty.clone()).confirmable(), None);
991 assert!(
992 dialog(empty).can_confirm(),
993 "an untouched calendar still lets OK close the dialog"
994 );
995 }
996
997 #[test]
998 fn input_mode_confirms_the_parsed_text_and_refuses_an_invalid_one() {
999 let valid = seeded()
1000 .with_entry_mode(DatePickerEntryMode::Input)
1001 .with_input_text("06/15/2026");
1002 assert_eq!(dialog(valid.clone()).confirmable(), Some(d(2026, 6, 15)));
1003 assert!(dialog(valid).can_confirm());
1004
1005 for bad in ["08/2", "", "08/20/2045"] {
1006 let invalid = seeded()
1007 .with_entry_mode(DatePickerEntryMode::Input)
1008 .with_input_text(bad);
1009 assert_eq!(dialog(invalid.clone()).confirmable(), None, "{bad:?}");
1010 assert!(
1011 !dialog(invalid).can_confirm(),
1012 "OK must refuse {bad:?} rather than confirm nothing"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn a_selection_the_predicate_refuses_is_not_confirmable() {
1019 let state = seeded().with_selected(d(2026, 8, 15));
1020 let refused = date_picker_dialog(state.clone(), FIRST(), LAST(), |_: &mut App, _| {})
1021 .selectable(|date| date.day() % 2 == 0);
1022 assert_eq!(refused.confirmable(), None);
1023
1024 let allowed = date_picker_dialog(state, FIRST(), LAST(), |_: &mut App, _| {})
1025 .selectable(|date| date.day() % 2 == 1);
1026 assert_eq!(allowed.confirmable(), Some(d(2026, 8, 15)));
1027 }
1028
1029 #[test]
1030 fn a_selection_outside_the_bounds_is_not_confirmable() {
1031 let state = DatePickerState::new(Some(d(2045, 1, 1)), d(2026, 8, 20));
1032 assert_eq!(dialog(state).confirmable(), None);
1033 }
1034
1035 #[test]
1038 fn ok_reports_the_confirmable_date_once() {
1039 let mut app = App::default();
1040 dialog(seeded()).confirm(&mut app);
1041 assert_eq!(app.confirmed, vec![Some(d(2026, 8, 20))]);
1042 assert_eq!(app.dismissed, 0);
1043 }
1044
1045 #[test]
1046 fn ok_with_nothing_selected_still_confirms_an_empty_pick() {
1047 let mut app = App::default();
1048 dialog(DatePickerState::new(None, d(2026, 8, 20))).confirm(&mut app);
1049 assert_eq!(app.confirmed, vec![None]);
1050 }
1051
1052 #[test]
1053 fn ok_in_input_mode_reports_nothing_for_an_invalid_entry() {
1054 let mut app = App::default();
1055 dialog(
1056 seeded()
1057 .with_entry_mode(DatePickerEntryMode::Input)
1058 .with_input_text("08/2"),
1059 )
1060 .confirm(&mut app);
1061 assert!(app.confirmed.is_empty(), "an invalid entry never confirms");
1062 assert_eq!(app.dismissed, 0, "and never dismisses either");
1063 }
1064
1065 #[test]
1066 fn ok_in_input_mode_confirms_the_typed_date() {
1067 let mut app = App::default();
1068 dialog(
1069 seeded()
1070 .with_entry_mode(DatePickerEntryMode::Input)
1071 .with_input_text("06/15/2026"),
1072 )
1073 .confirm(&mut app);
1074 assert_eq!(app.confirmed, vec![Some(d(2026, 6, 15))]);
1075 }
1076
1077 #[test]
1078 fn cancel_fires_the_dismiss_callback() {
1079 let mut app = App::default();
1080 dialog(seeded()).cancel(&mut app);
1081 assert_eq!(app.dismissed, 1);
1082 assert!(app.confirmed.is_empty());
1083 }
1084
1085 #[test]
1086 fn cancel_stages_through_the_dismiss_handle_when_one_is_installed() {
1087 let dismiss = ModalDismiss::new();
1091 let mut app = App::default();
1092 dialog(seeded())
1093 .dismiss_handle(dismiss.clone())
1094 .cancel(&mut app);
1095 assert_eq!(app.dismissed, 0, "the unstaged callback is not fired");
1096 assert!(app.confirmed.is_empty());
1097
1098 let dismiss = ModalDismiss::new();
1102 let picker = dialog(seeded()).dismiss_handle(dismiss.clone());
1103 let closed = Rc::new(Cell::new(0u32));
1104 let hook = closed.clone();
1105 let view = picker.compose().on_close(move || hook.set(hook.get() + 1));
1106 let mut counter = 0u64;
1107 let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
1108 let window = Size::new(600.0, 800.0);
1109 let mut tcx = TextContext::new();
1110 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1111 w.layout(&mut lctx, &BoxConstraints::tight(window));
1112 for ms in [0.0, 400.0] {
1113 let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(ms));
1114 w.paint(&mut ctx, &mut NoopScene);
1115 }
1116 assert!(!w.is_exiting(), "entered");
1117
1118 picker.cancel(&mut app);
1119 let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(500.0));
1120 w.paint(&mut ctx, &mut NoopScene);
1121 assert!(w.is_exiting(), "Cancel stages the host's exit ramp");
1122 assert_eq!(closed.get(), 0, "and pops nothing mid-ramp");
1123
1124 let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(2000.0));
1125 w.paint(&mut ctx, &mut NoopScene);
1126 assert_eq!(closed.get(), 1, "the pop lands when the ramp settles");
1127 assert_eq!(app.dismissed, 0, "never through the unstaged callback");
1128 }
1129
1130 #[test]
1131 fn cancel_with_a_handle_but_no_staged_route_still_dismisses() {
1132 let dismiss = ModalDismiss::new();
1138 let picker = dialog(seeded()).dismiss_handle(dismiss.clone());
1139 let mut counter = 0u64;
1140 let mut w = View::<App>::build(&picker, &mut BuildCtx::new(&mut counter));
1141 let window = Size::new(600.0, 800.0);
1142 let mut tcx = TextContext::new();
1143 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1144 w.layout(&mut lctx, &BoxConstraints::tight(window));
1145 for ms in [0.0, 400.0] {
1146 let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(ms));
1147 w.paint(&mut ctx, &mut NoopScene);
1148 }
1149
1150 let mut app = App::default();
1151 picker.cancel(&mut app);
1152 assert_eq!(
1153 app.dismissed, 0,
1154 "Cancel fires nothing itself — the handle owns the dismissal"
1155 );
1156
1157 let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(500.0));
1160 w.paint(&mut ctx, &mut NoopScene);
1161 assert_eq!(app.dismissed, 0);
1162 assert!(!w.is_exiting(), "there is nothing to stage on this mount");
1163
1164 for _ in 0..2 {
1166 let state_any: &mut dyn Any = &mut app;
1167 let mut ectx = EventCtx::new(state_any, Point::ORIGIN, window);
1168 w.event(
1169 &mut ectx,
1170 &InputEvent::Pointer(PointerEvent {
1171 phase: PointerPhase::Move,
1172 position: Point::new(5.0, 5.0),
1173 button: PointerButton::Primary,
1174 }),
1175 );
1176 }
1177 assert_eq!(app.dismissed, 1, "Cancel is not dead on a `Stack` mount");
1178 }
1179
1180 fn ft_ms(ms: f64) -> FrameTime {
1182 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1183 }
1184
1185 struct NoopScene;
1187
1188 impl PaintScene for NoopScene {
1189 fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
1190 fn draw_text(&mut self, _origin: Point, _text: &str) {}
1191 }
1192
1193 #[test]
1194 fn the_modal_content_contract_reports_the_dismissable_flag() {
1195 assert!(OverlayModalContent::modal_dismissable(&dialog(seeded())));
1196 assert!(!OverlayModalContent::modal_dismissable(
1197 &dialog(seeded()).dismissable(false)
1198 ));
1199 }
1200
1201 #[test]
1202 fn the_installed_dismiss_callback_replaces_the_builders() {
1203 let installed = OverlayModalContent::on_modal_dismiss(
1206 dialog(seeded()),
1207 Rc::new(|app: &mut App| app.dismissed += 100),
1208 );
1209 let mut app = App::default();
1210 installed.cancel(&mut app);
1211 assert_eq!(app.dismissed, 100);
1212 }
1213
1214 #[test]
1217 fn the_mode_affordance_exists_only_for_an_unlocked_entry_mode() {
1218 assert!(dialog(seeded()).toggle_view().is_some());
1219 assert!(
1220 dialog(seeded().with_entry_mode(DatePickerEntryMode::Input))
1221 .toggle_view()
1222 .is_some()
1223 );
1224 assert!(
1225 dialog(seeded().with_entry_mode(DatePickerEntryMode::CalendarOnly))
1226 .toggle_view()
1227 .is_none()
1228 );
1229 assert!(
1230 dialog(seeded().with_entry_mode(DatePickerEntryMode::InputOnly))
1231 .toggle_view()
1232 .is_none()
1233 );
1234 }
1235
1236 #[test]
1237 fn the_panel_width_follows_the_entry_mode() {
1238 assert_eq!(
1239 dialog(seeded()).panel_width(),
1240 DIALOG_PORTRAIT_CALENDAR_WIDTH
1241 );
1242 assert_eq!(
1243 dialog(seeded().with_entry_mode(DatePickerEntryMode::Input)).panel_width(),
1244 DIALOG_PORTRAIT_INPUT_WIDTH
1245 );
1246 }
1247
1248 #[test]
1249 fn the_headline_shows_the_selection_or_the_placeholder() {
1250 assert_eq!(dialog(seeded()).headline(), "Thu, Aug 20");
1251 assert_eq!(
1252 dialog(DatePickerState::new(None, d(2026, 8, 20))).headline(),
1253 DatePickerStrings::ENGLISH.no_date_selected
1254 );
1255 }
1256
1257 #[test]
1260 fn the_panel_builds_and_reconciles_across_an_entry_mode_swap() {
1261 let mut counter = 0u64;
1264 let calendar = dialog(seeded());
1265 let mut element = View::<App>::build(&calendar, &mut BuildCtx::new(&mut counter));
1266 let input = dialog(seeded().with_entry_mode(DatePickerEntryMode::Input));
1267 let flags = View::<App>::rebuild(
1268 &input,
1269 &calendar,
1270 &mut element,
1271 &mut BuildCtx::new(&mut counter),
1272 );
1273 assert!(!flags.is_empty(), "an entry-mode swap dirties the panel");
1274
1275 let locked = dialog(seeded().with_entry_mode(DatePickerEntryMode::CalendarOnly));
1277 View::<App>::rebuild(
1278 &locked,
1279 &input,
1280 &mut element,
1281 &mut BuildCtx::new(&mut counter),
1282 );
1283 View::<App>::teardown(&locked, &mut element, &mut BuildCtx::new(&mut counter));
1284 }
1285
1286 fn header_panel() -> DatePickerPanel<()> {
1291 DatePickerPanel {
1292 help: DatePickerStrings::ENGLISH.help_text.to_string(),
1293 headline: "Thu, Aug 20".to_string(),
1294 toggle: None,
1295 body: leaf_any(10.0, 10.0),
1296 body_inset: 0.0,
1297 cancel: leaf_any(10.0, 10.0),
1298 confirm: leaf_any(10.0, 10.0),
1299 }
1300 }
1301
1302 fn build_header_panel() -> DatePickerPanelWidget {
1303 let mut counter = 0u64;
1304 View::<()>::build(&header_panel(), &mut BuildCtx::new(&mut counter))
1305 }
1306
1307 fn theme_with_header(help: FontFamily, headline: FontFamily) -> Theme {
1310 let mut theme = crate::baseline();
1311 theme.type_scale.label_large.family = help;
1312 theme.type_scale.headline_large.family = headline;
1313 theme
1314 }
1315
1316 fn layout_header(w: &mut DatePickerPanelWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
1319 let mut ctx =
1320 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1321 w.layout(
1322 &mut ctx,
1323 &BoxConstraints::tight(Size::new(DIALOG_PORTRAIT_CALENDAR_WIDTH, 600.0)),
1324 );
1325 }
1326
1327 #[test]
1328 fn layout_takes_the_header_families_from_their_roles() {
1329 let mut w = build_header_panel();
1330 let mut tcx = TextContext::new();
1331 let (help, headline) = (
1332 FontFamily::named("Help Role Probe"),
1333 FontFamily::named("Headline Role Probe"),
1334 );
1335 layout_header(
1336 &mut w,
1337 &mut tcx,
1338 Some(&theme_with_header(help.clone(), headline.clone())),
1339 );
1340 assert_eq!(w.help.style().family, help, "the help line is labelLarge");
1341 assert_eq!(
1342 w.headline.style().family,
1343 headline,
1344 "the headline is headlineLarge"
1345 );
1346 assert_eq!(w.help.style().size, HELP_TEXT_SIZE);
1348 assert_eq!(w.headline.style().size, HEADLINE_TEXT_SIZE);
1349 }
1350
1351 #[test]
1352 fn without_a_theme_the_header_keeps_the_unthemed_styles() {
1353 let mut w = build_header_panel();
1354 let mut tcx = TextContext::new();
1355 layout_header(&mut w, &mut tcx, None);
1356 assert_eq!(
1357 w.help.style(),
1358 TextStyle {
1359 weight: FontWeight::MEDIUM,
1360 ..TextStyle::new(HELP_TEXT_SIZE, SHAPING_INK)
1361 }
1362 );
1363 assert_eq!(
1364 w.headline.style(),
1365 TextStyle {
1366 weight: FontWeight::REGULAR,
1367 ..TextStyle::new(HEADLINE_TEXT_SIZE, SHAPING_INK)
1368 }
1369 );
1370 }
1371
1372 #[test]
1373 fn a_theme_swap_reshapes_the_cached_header_runs() {
1374 let mut w = build_header_panel();
1375 let mut tcx = TextContext::new();
1376 let first = theme_with_header(
1377 FontFamily::named("Header Swap Probe A"),
1378 FontFamily::named("Header Swap Probe A"),
1379 );
1380 layout_header(&mut w, &mut tcx, Some(&first));
1381
1382 let settled = tcx.shape_cache_stats();
1384 layout_header(&mut w, &mut tcx, Some(&first));
1385 assert_eq!(
1386 tcx.shape_cache_stats(),
1387 settled,
1388 "an unchanged theme reshapes nothing"
1389 );
1390
1391 let second = theme_with_header(
1392 FontFamily::named("Header Swap Probe B"),
1393 FontFamily::named("Header Swap Probe B"),
1394 );
1395 layout_header(&mut w, &mut tcx, Some(&second));
1396 assert_eq!(
1397 tcx.shape_cache_stats().shapes,
1398 settled.shapes + 2,
1399 "a family swap must reshape both header runs, not serve the old face"
1400 );
1401 }
1402
1403 #[cfg(feature = "bundled-fonts")]
1405 fn shaped_font(run: &HeaderRun) -> Vec<u8> {
1406 let layout = run.layout.as_ref().expect("the run was shaped");
1407 let runs = layout.to_scene_runs(Point::ZERO);
1408 let first = runs
1409 .first()
1410 .expect("the run shaped to at least one glyph run");
1411 first.font.font().data.as_ref().to_vec()
1412 }
1413
1414 #[cfg(feature = "bundled-fonts")]
1415 #[test]
1416 fn a_theme_swap_between_registered_families_changes_the_header_face() {
1417 let (flex, mono) = (crate::tokens::font_data()[0], crate::tokens::font_data()[1]);
1419 let mut tcx = TextContext::new();
1420 for bytes in [flex, mono] {
1421 tcx.register_fonts(bytes.to_vec())
1422 .expect("the bundled faces must register");
1423 }
1424 let flex_family = || FontFamily::named(crate::tokens::ROBOTO_FLEX_FAMILY);
1425 let mono_family = || FontFamily::named(crate::tokens::ROBOTO_MONO_FAMILY);
1426 let mut w = build_header_panel();
1427
1428 layout_header(
1429 &mut w,
1430 &mut tcx,
1431 Some(&theme_with_header(flex_family(), flex_family())),
1432 );
1433 assert!(
1434 shaped_font(&w.help) == flex,
1435 "labelLarge shapes the help line"
1436 );
1437 assert!(
1438 shaped_font(&w.headline) == flex,
1439 "headlineLarge shapes the headline"
1440 );
1441
1442 layout_header(
1443 &mut w,
1444 &mut tcx,
1445 Some(&theme_with_header(mono_family(), mono_family())),
1446 );
1447 assert!(
1448 shaped_font(&w.help) == mono,
1449 "after a theme swap the help line must shape in the new family"
1450 );
1451 assert!(
1452 shaped_font(&w.headline) == mono,
1453 "after a theme swap the headline must shape in the new family"
1454 );
1455 }
1456
1457 #[test]
1458 fn a_calendar_sub_view_change_reconciles_in_place() {
1459 let mut counter = 0u64;
1460 let day = dialog(seeded());
1461 let mut element = View::<App>::build(&day, &mut BuildCtx::new(&mut counter));
1462 let year = dialog(seeded().with_mode(DatePickerMode::Year));
1463 let flags =
1464 View::<App>::rebuild(&year, &day, &mut element, &mut BuildCtx::new(&mut counter));
1465 assert!(!flags.is_empty());
1466 }
1467}