1#[cfg(test)]
69mod tests;
70
71use std::rc::Rc;
72use teksilo_i18n::lit;
73use teksilo_i18n::localized;
74
75use jiff::civil::Weekday;
76use teksilo_canvas::{Path, Point, Rect, SizeProposal};
77use teksilo_core::accessibility::AccessNodeBuilder;
78use teksilo_core::accesskit::{Action, Role};
79use teksilo_core::build_context::BuildContext;
80use teksilo_core::event::{EventResponse, Key, WidgetEvent};
81use teksilo_core::overlay::{
82 DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
83};
84use teksilo_core::signal::{Prop, Signal};
85use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
86use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
87use teksilo_core::widget_id::WidgetId;
88use teksilo_i18n::resolve_message_widget;
89use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
90
91use crate::calendar::Calendar;
92use crate::common::datetime::pattern::{
93 ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
94 segment_at_position, step_date_field, step_time_field,
95};
96use crate::common::datetime::types::today_local;
97use crate::common::datetime::{Date, DateTime, Time};
98use crate::date_edit::{ValidationBehavior, build_date_validator, calendar_glyph_icon, clamp_date};
99use crate::icon_button::{IconButton, IconButtonSize};
100use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
101use crate::primitives::{
102 Center, FixedSize, HStack, IconWidget, MinSize, Padding, RectWidget, TextWidget, VStack, ZStack,
103};
104use crate::time_edit::{
105 SecondsMode, TimeFormat, build_time_validator, clamp_time, time_pattern_for,
106};
107use teksilo_i18n::LocalizedString;
108
109type OnValueChanged = Rc<dyn Fn(Option<DateTime>, &mut EventContext)>;
110
111pub struct DateTimeEdit {
114 value: Signal<Option<DateTime>>,
115 pub(crate) date_part: Signal<Option<Date>>,
118 pub(crate) time_part: Signal<Option<Time>>,
119 date_text: Signal<String>,
120 time_text: Signal<String>,
121 required_source: Option<Signal<DateTime>>,
124 date_format_pattern: Option<String>,
125 time_format: Option<TimeFormat>,
128 seconds: SecondsMode,
129 min: Option<DateTime>,
130 max: Option<DateTime>,
131 step_minutes: u32,
132 first_day_of_week: Option<Weekday>,
133 show_calendar_button: bool,
134 separator: Option<String>,
138 placeholder: LocalizedString,
139 enabled: Prop<bool>,
142 read_only: bool,
143 label: Option<LocalizedString>,
144 validation_behavior: ValidationBehavior,
145 time_width_policy: crate::date_edit::WidthPolicy,
151 feedback: Signal<ValidationFeedback>,
153 focused: Signal<bool>,
156 calendar_popover_open: Signal<bool>,
159 on_value_changed: Option<OnValueChanged>,
160 style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
161 root_child_id: Option<WidgetId>,
162 calendar_id: Option<WidgetId>,
163 tooltip_text: Option<LocalizedString>,
167 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
169 composite_tooltip_content: Option<Box<dyn Widget>>,
171}
172
173impl std::fmt::Debug for DateTimeEdit {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 f.debug_struct("DateTimeEdit").finish_non_exhaustive()
176 }
177}
178
179impl DateTimeEdit {
180 pub fn new(value: Signal<Option<DateTime>>) -> Self {
182 let initial = value.get();
183 let date_part = Signal::new(initial.map(|dt| dt.date()));
184 let time_part = Signal::new(initial.map(|dt| dt.time()));
185 Self {
186 value,
187 date_part,
188 time_part,
189 date_text: Signal::new(String::new()),
190 time_text: Signal::new(String::new()),
191 required_source: None,
192 date_format_pattern: None,
193 time_format: None,
194 seconds: SecondsMode::Hidden,
195 min: None,
196 max: None,
197 step_minutes: 1,
198 first_day_of_week: None,
199 show_calendar_button: true,
200 separator: None,
201 placeholder: LocalizedString::literal(String::new()),
202 enabled: Prop::Static(true),
203 read_only: false,
204 label: None,
205 validation_behavior: ValidationBehavior::AutoCorrect,
206 time_width_policy: crate::date_edit::WidthPolicy::Default,
207 feedback: Signal::new(ValidationFeedback::Pristine),
208 focused: Signal::new(false),
209 calendar_popover_open: Signal::new(false),
210 on_value_changed: None,
211 style_override: None,
212 root_child_id: None,
213 calendar_id: None,
214 tooltip_text: None,
215 rich_tooltip_source: None,
216 composite_tooltip_content: None,
217 }
218 }
219
220 pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
222 self.style_override = Some(std::rc::Rc::new(style));
223 self
224 }
225
226 pub fn required(value: Signal<DateTime>) -> Self {
230 let proxy: Signal<Option<DateTime>> = Signal::new(Some(value.get()));
231 let mut s = Self::new(proxy);
232 s.required_source = Some(value);
233 s
234 }
235
236 pub fn date_format_pattern(mut self, p: impl Into<String>) -> Self {
239 self.date_format_pattern = Some(p.into());
240 self
241 }
242
243 pub fn time_format(mut self, f: TimeFormat) -> Self {
248 self.time_format = Some(f);
249 self
250 }
251
252 pub fn seconds(mut self, mode: SecondsMode) -> Self {
254 self.seconds = mode;
255 self
256 }
257
258 pub fn min(mut self, dt: DateTime) -> Self {
261 self.min = Some(dt);
262 self
263 }
264
265 pub fn max(mut self, dt: DateTime) -> Self {
268 self.max = Some(dt);
269 self
270 }
271
272 pub fn step_minutes(mut self, n: u32) -> Self {
283 self.step_minutes = n.max(1);
284 self
285 }
286
287 pub fn first_day_of_week(mut self, w: Weekday) -> Self {
289 self.first_day_of_week = Some(w);
290 self
291 }
292
293 pub fn show_calendar_button(mut self, show: bool) -> Self {
295 self.show_calendar_button = show;
296 self
297 }
298
299 pub fn separator(mut self, s: impl Into<String>) -> Self {
303 self.separator = Some(s.into());
304 self
305 }
306
307 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
309 let ls: LocalizedString = text.into();
310 self.placeholder = ls;
311 self
312 }
313
314 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
317 self.enabled = enabled.into();
318 self
319 }
320
321 pub fn read_only(mut self, read_only: bool) -> Self {
323 self.read_only = read_only;
324 self
325 }
326
327 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
330 let ls: LocalizedString = label.into();
331 self.label = Some(ls);
332 self
333 }
334
335 pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
338 self.validation_behavior = behavior;
339 self
340 }
341
342 pub fn time_width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
349 self.time_width_policy = policy;
350 self
351 }
352
353 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
357 self.feedback.clone()
358 }
359
360 pub fn on_value_changed(
363 mut self,
364 f: impl Fn(Option<DateTime>, &mut EventContext) + 'static,
365 ) -> Self {
366 self.on_value_changed = Some(Rc::new(f));
367 self
368 }
369
370 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
375 self.tooltip_text = Some(text.into());
376 self.rich_tooltip_source = None;
377 self.composite_tooltip_content = None;
378 self
379 }
380
381 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
386 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
387 self.tooltip_text = None;
388 self.composite_tooltip_content = None;
389 self
390 }
391
392 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
396 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
397 self.tooltip_text = None;
398 self.composite_tooltip_content = None;
399 self
400 }
401
402 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
407 self.composite_tooltip_content = Some(Box::new(content));
408 self.tooltip_text = None;
409 self.rich_tooltip_source = None;
410 self
411 }
412
413 pub fn value(&self) -> Signal<Option<DateTime>> {
415 self.value.clone()
416 }
417}
418
419impl Widget for DateTimeEdit {
420 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
421 let theme = ctx.theme_signal().get();
422 use crate::styles::recipe_date_edit_style as de;
423 use crate::styles::recipe_text_input_style as field_dims;
424 let focus_ring_width = theme.shape.focus_ring_width;
425 let self_id = ctx.self_id();
426 ctx.enabled_when(self_id, self.enabled.clone());
428 let read_only = self.read_only;
429
430 if let Some(src) = self.required_source.clone() {
432 {
433 let proxy = self.value.clone();
434 ctx.effect(&src, move |new| {
435 if proxy.get() != Some(*new) {
436 proxy.set(Some(*new));
437 }
438 });
439 }
440 {
441 let src_clone = src;
442 ctx.effect(&self.value, move |v| {
443 if let Some(dt) = v
444 && src_clone.get() != *dt
445 {
446 src_clone.set(*dt);
447 }
448 });
449 }
450 }
451
452 ctx.locale_signal().bind_to(
462 ctx.self_id(),
463 ctx.binding_registry(),
464 teksilo_core::binding::BindingLevel::Rebuild,
465 );
466
467 let date_pattern_string = self.date_format_pattern.clone().unwrap_or_else(|| {
468 let tag = ctx.locale_signal().get().unwrap_or_default();
469 crate::common::datetime::format_pattern_for_locale(&tag).to_string()
470 });
471 let date_pattern = ParsedPattern::parse(&date_pattern_string)
472 .unwrap_or_else(|_| ParsedPattern::parse("%Y-%m-%d").unwrap());
473 let date_pattern_rc = Rc::new(date_pattern);
474 let date_mask = mask_for_pattern(&date_pattern_rc);
475
476 let time_format = self.time_format.unwrap_or_else(|| {
477 let tag = ctx.locale_signal().get().unwrap_or_default();
478 if crate::common::datetime::prefers_12_hour_clock(&tag) {
479 TimeFormat::Hour12
480 } else {
481 TimeFormat::Hour24
482 }
483 });
484 let time_pattern_string = time_pattern_for(time_format, self.seconds);
485 let time_pattern = ParsedPattern::parse(&time_pattern_string)
486 .unwrap_or_else(|_| ParsedPattern::parse("%H:%M").unwrap());
487 let time_pattern_rc = Rc::new(time_pattern);
488 let time_mask = mask_for_pattern(&time_pattern_rc);
489
490 let date_min = self.min.map(|dt| dt.date());
491 let date_max = self.max.map(|dt| dt.date());
492 let time_min = self.min.map(|dt| dt.time());
493 let time_max = self.max.map(|dt| dt.time());
494
495 {
501 let date_part = self.date_part.clone();
502 let time_part = self.time_part.clone();
503 let date_text = self.date_text.clone();
504 let time_text = self.time_text.clone();
505 let date_pattern = date_pattern_rc.clone();
506 let time_pattern = time_pattern_rc.clone();
507 ctx.effect(&self.value, move |new_dt| {
508 let new_d = new_dt.map(|dt| dt.date());
509 let new_t = new_dt.map(|dt| dt.time());
510 if date_part.get() != new_d {
511 date_part.set(new_d);
512 }
513 if time_part.get() != new_t {
514 time_part.set(new_t);
515 }
516 let d_text = new_d
517 .map(|d| format_value(&date_pattern, Some(d), None))
518 .unwrap_or_default();
519 let t_text = new_t
520 .map(|t| format_value(&time_pattern, None, Some(t)))
521 .unwrap_or_default();
522 if date_text.get() != d_text {
523 date_text.set(d_text);
524 }
525 if time_text.get() != t_text {
526 time_text.set(t_text);
527 }
528 });
529 }
530 {
533 self.date_text.set(
534 self.date_part
535 .get()
536 .map(|d| format_value(&date_pattern_rc, Some(d), None))
537 .unwrap_or_default(),
538 );
539 self.time_text.set(
540 self.time_part
541 .get()
542 .map(|t| format_value(&time_pattern_rc, None, Some(t)))
543 .unwrap_or_default(),
544 );
545 }
546
547 let (date_field_id, date_inner_id) =
550 self.build_date_half(ctx, date_pattern_rc.clone(), &date_mask, date_min, date_max);
551 let (time_field_id, time_inner_id) =
552 self.build_time_half(ctx, time_pattern_rc.clone(), &time_mask, time_min, time_max);
553
554 let separator_id = match self.separator.as_deref() {
559 None => {
560 let dot = middle_dot_icon(field_dims::TEXT_FIELD_HEIGHT * 0.4)
561 .color(teksilo_tokens::TextRole::Secondary);
562 ctx.add(
563 FixedSize::new()
564 .width(field_dims::TEXT_FIELD_HEIGHT * 0.55)
565 .height(field_dims::TEXT_FIELD_HEIGHT)
566 .child(Center::new().child(dot)),
567 )
568 }
569 Some(s) if s.is_empty() => ctx.add(
570 FixedSize::new()
571 .width(0.0_f32)
572 .height(field_dims::TEXT_FIELD_HEIGHT),
573 ),
574 Some(s) => {
575 let text = TextWidget::new(lit!(s))
576 .style(teksilo_tokens::TextStyleRole::Body)
577 .color(teksilo_tokens::TextRole::Secondary)
578 .single_line()
579 .a11y_hidden();
580 ctx.add(Padding::new(0.0, 6.0, 0.0, 6.0).child(Center::new().child(text)))
581 }
582 };
583
584 let trigger_id_opt = if self.show_calendar_button {
586 let calendar_temp: Signal<Option<Date>> = Signal::new(self.date_part.get());
591 {
592 let temp = calendar_temp.clone();
593 ctx.effect(&self.date_part, move |new_d| {
594 if temp.get() != *new_d {
595 temp.set(*new_d);
596 }
597 });
598 }
599 let popover_open = self.calendar_popover_open.clone();
600 let date_part = self.date_part.clone();
601 let date_text = self.date_text.clone();
602 let date_pattern = date_pattern_rc.clone();
603 let value_outer = self.value.clone();
604 let time_part = self.time_part.clone();
605 let on_changed = self.on_value_changed.clone();
606 let return_focus_to = ctx.self_id();
607 let mut calendar =
608 Calendar::single(calendar_temp.clone()).on_activate(move |d, ctx_evt| {
609 let clamped = clamp_date(d, date_min, date_max);
610 date_part.set(Some(clamped));
611 date_text.set(format_value(&date_pattern, Some(clamped), None));
612 let combined = match (Some(clamped), time_part.get()) {
613 (Some(d), Some(t)) => Some(d.to_datetime(t)),
614 _ => None,
615 };
616 if value_outer.get() != combined {
617 value_outer.set(combined);
618 if let Some(cb) = on_changed.as_ref() {
619 cb(combined, ctx_evt);
620 }
621 }
622 popover_open.set(false);
623 ctx_evt.dismiss_self_overlay_chain();
624 ctx_evt.request_focus(return_focus_to);
625 ctx_evt.request_frame();
626 });
627 if let Some(min) = date_min {
628 calendar = calendar.min_date(min);
629 }
630 if let Some(max) = date_max {
631 calendar = calendar.max_date(max);
632 }
633 if let Some(fdow) = self.first_day_of_week {
634 calendar = calendar.first_day_of_week(fdow);
635 }
636 let cal_id = ctx.add_deferred(self.calendar_popover_open.clone(), calendar);
639 ctx.set_dormant(cal_id);
640 self.calendar_id = Some(cal_id);
641
642 let popover_open = self.calendar_popover_open.clone();
643 let self_ref = ctx.self_id();
644 let dismiss_cb: OverlayDismissCallback = {
645 let popover_open = popover_open.clone();
646 Rc::new(move |_, _| {
647 popover_open.set(false);
648 })
649 };
650 let trigger_enabled = self.enabled.as_signal().map(move |on| *on && !read_only);
651 let trigger_btn = IconButton::new(calendar_glyph_icon(de::CALENDAR_ICON_SIZE))
652 .embedded()
653 .size(IconButtonSize::Default)
654 .enabled(trigger_enabled)
655 .tooltip(localized(move || {
656 resolve_message_widget("date-time-edit-trigger-tooltip", &[])
657 }))
658 .on_activate_fn(move |ctx_evt: &mut EventContext| {
659 if popover_open.get() {
660 popover_open.set(false);
661 ctx_evt.dismiss_all_except_hosts();
662 } else {
663 popover_open.set(true);
664 ctx_evt.materialize_now(cal_id);
667 ctx_evt.activate(cal_id);
668 ctx_evt.show_overlay(OverlayRequest {
669 content_id: cal_id,
670 anchor: self_ref,
671 placement: OverlayPlacement::BelowPreferred,
672 dismiss: DismissBehavior::EscapeOrClickOutside,
673 layer: OverlayLayer::InTree,
674 parent_overlay: None,
675 on_dismiss: Some(dismiss_cb.clone()),
676 fade_duration: None,
677 });
678 ctx_evt.request_focus(cal_id);
679 }
680 });
681 Some(ctx.add(trigger_btn))
682 } else {
683 None
684 };
685
686 let date_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child(date_field_id));
694 let time_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child(time_field_id));
695 let mut row = HStack::new()
696 .spacing(0.0)
697 .child(date_shrinkable)
698 .child(separator_id)
699 .child(time_shrinkable);
700 if let Some(trigger_id) = trigger_id_opt {
701 row = row.child(trigger_id);
702 }
703 let inline_row_id = ctx.add(row);
704 let row_id = ctx.add(
705 Padding::new(
706 0.0,
707 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
708 0.0,
709 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
710 )
711 .child(inline_row_id),
712 );
713
714 let feedback_for_border = self.feedback.clone();
716 let focused_for_border = self.focused.clone();
717 let border_role =
718 focused_for_border
719 .clone()
720 .zip(&feedback_for_border)
721 .map(|(focused, fb)| match fb {
722 ValidationFeedback::Invalid { .. } => BorderRole::Error,
723 ValidationFeedback::Corrected { .. } if !*focused => BorderRole::Focused,
724 _ => {
725 if *focused {
726 BorderRole::Focused
727 } else {
728 BorderRole::Default
729 }
730 }
731 });
732 let border_width_signal =
733 focused_for_border
734 .clone()
735 .zip(&feedback_for_border)
736 .map(move |(focused, fb)| {
737 if *focused || matches!(fb, ValidationFeedback::Invalid { .. }) {
738 focus_ring_width
739 } else {
740 field_dims::TEXT_FIELD_BORDER_WIDTH
741 }
742 });
743 let bg = RectWidget::new()
744 .background(SurfaceRole::Content)
745 .border_color(border_role)
746 .border_width(border_width_signal)
747 .corner_radius(CornerRadius::uniform(field_dims::TEXT_FIELD_CORNER_RADIUS));
748 let bg_id = ctx.add(bg);
749 let framed_id = ctx.add(ZStack::new().child(bg_id).child(row_id));
750 let sized_id = ctx.add(
751 MinSize::new(
752 0.0,
753 crate::styles::TextInputRecipe::for_tokens(&ctx.theme().input).height,
754 )
755 .child(framed_id),
756 );
757
758 let strip_id = ctx.add(crate::primitives::ValidationStrip::new(
760 self.feedback.clone(),
761 ));
762 ctx.access_described_by(date_inner_id, strip_id);
765 ctx.access_described_by(time_inner_id, strip_id);
766 let framed_in_vstack = ctx.add(
773 crate::primitives::Expand::horizontal()
774 .respect_intrinsic()
775 .child(sized_id),
776 );
777 let root_with_strip = ctx.add(
778 VStack::new()
779 .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
780 .child(framed_in_vstack)
781 .child(strip_id),
782 );
783 let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
784 &self.style_override,
785 ctx,
786 );
787 let cfg = teksilo_core::styles::DateEditStyleConfig {
788 body: root_with_strip,
789 };
790 let root_id = style.make_body(&cfg, ctx);
791 self.root_child_id = Some(root_id);
792
793 if let Some(content) = self.composite_tooltip_content.take() {
795 let delay = ctx.theme().motion.tooltip_delay_heavy;
796 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
797 } else if let Some(source) = self.rich_tooltip_source.clone() {
798 let delay = ctx.theme().motion.tooltip_delay;
799 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
800 } else if let Some(text) = self.tooltip_text.clone() {
801 let delay = ctx.theme().motion.tooltip_delay;
802 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
803 }
804
805 let handlers = HandlerSet::new().focus_within(self.focused.clone());
807 ctx.apply_self_handlers(handlers);
808
809 let self_id = ctx.self_id();
813 self.value.bind_to(
814 self_id,
815 ctx.binding_registry(),
816 teksilo_core::binding::BindingLevel::AccessibilityOnly,
817 );
818 self.feedback.bind_to(
819 self_id,
820 ctx.binding_registry(),
821 teksilo_core::binding::BindingLevel::AccessibilityOnly,
822 );
823 self.calendar_popover_open.bind_to(
824 self_id,
825 ctx.binding_registry(),
826 teksilo_core::binding::BindingLevel::AccessibilityOnly,
827 );
828
829 let mut out = vec![root_with_strip];
835 if let Some(cal_id) = self.calendar_id {
836 out.push(cal_id);
837 }
838 out
839 }
840
841 fn layout_response(
842 &self,
843 proposal: SizeProposal,
844 ctx: &LayoutContext,
845 ) -> teksilo_core::widget::LayoutResponse {
846 let response = match self.root_child_id {
851 Some(id) => ctx
852 .child_layout_response(id, proposal)
853 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
854 None => proposal.resolve(0.0, 0.0).into(),
855 };
856 if self.time_width_policy == crate::date_edit::WidthPolicy::Fill {
857 teksilo_core::widget::LayoutResponse::flexible(response.size, 1.0)
858 } else {
859 response
860 }
861 }
862
863 fn place_children(
864 &self,
865 bounds: Rect,
866 _proposal: SizeProposal,
867 children: &mut [WidgetPlacement],
868 _ctx: &LayoutContext,
869 ) {
870 for child in children.iter_mut() {
874 if Some(child.id) == self.calendar_id {
875 child.size = teksilo_canvas::Size::ZERO;
876 continue;
877 }
878 child.origin = bounds.origin();
879 child.size = bounds.size();
880 }
881 }
882
883 fn children(&self) -> Vec<WidgetId> {
884 let mut out = Vec::new();
885 if let Some(id) = self.root_child_id {
886 out.push(id);
887 }
888 if let Some(id) = self.calendar_id {
889 out.push(id);
890 }
891 out
892 }
893
894 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
895 builder.set_role(Role::DateTimeInput);
896 if let Some(ref label) = self.label {
897 builder.set_name(label.clone());
898 } else {
899 builder.set_name(resolve_message_widget("date-time-edit-name", &[]));
900 }
901 match self.value.get() {
902 Some(dt) => {
903 builder.set_value(format!(
904 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
905 dt.date().year(),
906 dt.date().month(),
907 dt.date().day(),
908 dt.time().hour(),
909 dt.time().minute(),
910 dt.time().second(),
911 ));
912 }
913 None => {
914 if !self.placeholder.resolve_now().is_empty() {
915 builder.set_placeholder(self.placeholder.resolve_now());
916 } else {
917 builder
918 .set_placeholder(resolve_message_widget("date-time-edit-placeholder", &[]));
919 }
920 }
921 }
922 if self.read_only {
924 builder.set_read_only();
925 }
926 if matches!(self.feedback.get(), ValidationFeedback::Invalid { .. }) {
927 builder
928 .inner_mut()
929 .set_invalid(teksilo_core::accesskit::Invalid::True);
930 }
931 if self.show_calendar_button {
932 builder
933 .inner_mut()
934 .set_has_popup(teksilo_core::accesskit::HasPopup::Grid);
935 builder.set_expanded(self.calendar_popover_open.get());
936 }
937 builder.add_action(Action::Focus);
938 }
939}
940
941impl DateTimeEdit {
942 fn build_date_half(
948 &self,
949 ctx: &mut BuildContext,
950 pattern_rc: Rc<ParsedPattern>,
951 mask_string: &str,
952 min: Option<Date>,
953 max: Option<Date>,
954 ) -> (WidgetId, WidgetId) {
955 let validator =
956 build_date_validator(pattern_rc.clone(), min, max, self.validation_behavior);
957
958 let outer_value = self.value.clone();
959 let on_changed = self.on_value_changed.clone();
960 let time_part = self.time_part.clone();
961 let merge_into_outer = move |new_d: Option<Date>, ctx_evt: &mut EventContext| {
962 let combined = match (new_d, time_part.get()) {
963 (Some(d), Some(t)) => Some(d.to_datetime(t)),
964 _ => None,
965 };
966 if outer_value.get() != combined {
967 outer_value.set(combined);
968 if let Some(cb) = on_changed.as_ref() {
969 cb(combined, ctx_evt);
970 }
971 }
972 };
973
974 let date_signal = self.date_part.clone();
975 let text_signal = self.date_text.clone();
976
977 let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
979 let text_signal = text_signal.clone();
980 let date_signal = date_signal.clone();
981 let pattern = pattern_rc.clone();
982 let merge = merge_into_outer.clone();
983 Rc::new(move |ctx_evt: &mut EventContext| {
984 let raw = text_signal.get();
985 let trimmed = raw.trim();
986 let (parsed, accepted): (Option<Date>, bool) = if trimmed.is_empty() {
987 (None, true)
988 } else {
989 match parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
990 Some(ParsedValue::Date(d)) => (Some(clamp_date(d, min, max)), true),
991 _ => (date_signal.get(), false),
992 }
993 };
994 if date_signal.get() != parsed {
995 date_signal.set(parsed);
996 }
997 merge(parsed, ctx_evt);
998 accepted
999 })
1000 };
1001
1002 self.build_field(
1003 ctx,
1004 text_signal.clone(),
1005 mask_string,
1006 validator,
1007 self.placeholder.clone(),
1008 commit,
1009 "date-time-edit-date-name",
1010 Role::DateInput,
1011 DateTimeHalfKind::Date {
1012 pattern: pattern_rc,
1013 date_signal,
1014 text_signal,
1015 min,
1016 max,
1017 merge: Rc::new(merge_into_outer),
1018 },
1019 )
1020 }
1021
1022 fn build_time_half(
1023 &self,
1024 ctx: &mut BuildContext,
1025 pattern_rc: Rc<ParsedPattern>,
1026 mask_string: &str,
1027 min: Option<Time>,
1028 max: Option<Time>,
1029 ) -> (WidgetId, WidgetId) {
1030 let validator =
1031 build_time_validator(pattern_rc.clone(), min, max, self.validation_behavior);
1032
1033 let outer_value = self.value.clone();
1034 let on_changed = self.on_value_changed.clone();
1035 let date_part = self.date_part.clone();
1036 let merge_into_outer = move |new_t: Option<Time>, ctx_evt: &mut EventContext| {
1037 let combined = match (date_part.get(), new_t) {
1038 (Some(d), Some(t)) => Some(d.to_datetime(t)),
1039 _ => None,
1040 };
1041 if outer_value.get() != combined {
1042 outer_value.set(combined);
1043 if let Some(cb) = on_changed.as_ref() {
1044 cb(combined, ctx_evt);
1045 }
1046 }
1047 };
1048
1049 let time_signal = self.time_part.clone();
1050 let text_signal = self.time_text.clone();
1051
1052 let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
1054 let text_signal = text_signal.clone();
1055 let time_signal = time_signal.clone();
1056 let pattern = pattern_rc.clone();
1057 let merge = merge_into_outer.clone();
1058 Rc::new(move |ctx_evt: &mut EventContext| {
1059 let raw = text_signal.get();
1060 let trimmed = raw.trim();
1061 let (parsed, accepted): (Option<Time>, bool) = if trimmed.is_empty() {
1062 (None, true)
1063 } else {
1064 match parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
1065 Some(ParsedValue::Time(t)) => (Some(clamp_time(t, min, max)), true),
1066 _ => (time_signal.get(), false),
1067 }
1068 };
1069 if time_signal.get() != parsed {
1070 time_signal.set(parsed);
1071 }
1072 merge(parsed, ctx_evt);
1073 accepted
1074 })
1075 };
1076
1077 self.build_field(
1078 ctx,
1079 text_signal.clone(),
1080 mask_string,
1081 validator,
1082 LocalizedString::literal(String::new()),
1083 commit,
1084 "date-time-edit-time-name",
1085 Role::TimeInput,
1086 DateTimeHalfKind::Time {
1087 pattern: pattern_rc,
1088 time_signal,
1089 text_signal,
1090 min,
1091 max,
1092 merge: Rc::new(merge_into_outer),
1093 },
1094 )
1095 }
1096
1097 #[allow(clippy::too_many_arguments)]
1102 fn build_field(
1103 &self,
1104 ctx: &mut BuildContext,
1105 text_signal: Signal<String>,
1106 mask_string: &str,
1107 validator: crate::primitives::text_input_field::ValidatorFn,
1108 placeholder: LocalizedString,
1109 commit: Rc<dyn Fn(&mut EventContext) -> bool>,
1110 a11y_label_key: &str,
1111 a11y_role: Role,
1112 kind: DateTimeHalfKind,
1113 ) -> (WidgetId, WidgetId) {
1114 use crate::styles::recipe_text_input_style as field_dims;
1115 let inner_height =
1116 (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
1117 let text_area_height =
1118 (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
1119
1120 let pattern_for_filter = match &kind {
1121 DateTimeHalfKind::Date { pattern, .. } => pattern.clone(),
1122 DateTimeHalfKind::Time { pattern, .. } => pattern.clone(),
1123 };
1124 let is_time_half = matches!(kind, DateTimeHalfKind::Time { .. });
1125 let mut field = TextInputField::new(text_signal.clone())
1126 .enabled(self.enabled.clone())
1127 .on_access_set_value({
1132 let text_signal = text_signal.clone();
1133 let commit = commit.clone();
1134 move |text: &str, ctx: &mut EventContext| {
1139 text_signal.set(text.to_string());
1140 commit(ctx)
1141 }
1142 })
1143 .read_only(self.read_only)
1144 .placeholder(placeholder)
1145 .text_height(text_area_height)
1146 .input_mask(mask_string)
1147 .validator({
1148 let v = validator.clone();
1149 move |s| (v)(s)
1150 })
1151 .char_filter(move |c: char| {
1152 if c.is_ascii_digit() || c == ' ' || c == ':' || c == '-' {
1153 return true;
1154 }
1155 if is_time_half && matches!(c, 'a' | 'A' | 'p' | 'P' | 'm' | 'M') {
1156 return true;
1157 }
1158 for tok in &pattern_for_filter.tokens {
1159 if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
1160 && s.chars().any(|x| x == c)
1161 {
1162 return true;
1163 }
1164 }
1165 false
1166 });
1167 {
1171 let inner_feedback = field.validation_feedback_signal();
1172 let composed = self.feedback.clone();
1173 ctx.effect(&inner_feedback, move |new_fb| {
1174 let merged = match (composed.get(), new_fb.clone()) {
1175 (a, b) if rank(&a) >= rank(&b) => a,
1176 (_, b) => b,
1177 };
1178 if composed.get() != merged {
1179 composed.set(merged);
1180 }
1181 });
1182 }
1183 {
1184 let commit = commit.clone();
1185 field = field.on_submit_fn(move |ctx_evt| {
1186 commit(ctx_evt);
1187 });
1188 }
1189 {
1190 let commit = commit.clone();
1191 field = field.on_blur_fn(move |ctx_evt| {
1192 commit(ctx_evt);
1193 });
1194 }
1195
1196 let caret = field.caret_position();
1197 let caret_setter = field.caret_setter();
1198
1199 let field_with_a11y = field
1200 .access_role(a11y_role)
1201 .access_label(resolve_message_widget(a11y_label_key, &[]));
1202 let field_id = ctx.add(field_with_a11y);
1203
1204 let padded_field_id = ctx.add(
1205 Padding::new(
1206 field_dims::TEXT_FIELD_PADDING_VERTICAL,
1207 4.0,
1208 field_dims::TEXT_FIELD_PADDING_VERTICAL,
1209 4.0,
1210 )
1211 .child(field_id),
1212 );
1213 let is_time = matches!(kind, DateTimeHalfKind::Time { .. });
1219 let sized_field_id =
1220 if is_time && self.time_width_policy == crate::date_edit::WidthPolicy::Fill {
1221 ctx.add(crate::primitives::Expand::horizontal().child(padded_field_id))
1222 } else {
1223 padded_field_id
1224 };
1225
1226 let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = match kind {
1229 DateTimeHalfKind::Date {
1230 pattern,
1231 date_signal,
1232 text_signal,
1233 min,
1234 max,
1235 merge,
1236 } => {
1237 let caret = caret.clone();
1238 let caret_setter = caret_setter.clone();
1239 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
1240 let pos = caret.get();
1241 let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
1242 return;
1243 };
1244 let current = date_signal.get().unwrap_or_else(today_local);
1245 let stepped = step_date_field(current, kind_seg, delta);
1246 let clamped = clamp_date(stepped, min, max);
1247 date_signal.set(Some(clamped));
1248 text_signal.set(format_value(&pattern, Some(clamped), None));
1249 caret_setter(pos);
1250 merge(Some(clamped), ctx_evt);
1251 ctx_evt.request_frame();
1252 })
1253 }
1254 DateTimeHalfKind::Time {
1255 pattern,
1256 time_signal,
1257 text_signal,
1258 min,
1259 max,
1260 merge,
1261 } => {
1262 let caret = caret.clone();
1263 let caret_setter = caret_setter.clone();
1264 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
1265 let pos = caret.get();
1266 let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
1267 return;
1268 };
1269 let current = time_signal.get().unwrap_or_else(Time::midnight);
1270 let stepped = step_time_field(current, kind_seg, delta);
1271 let clamped = clamp_time(stepped, min, max);
1272 time_signal.set(Some(clamped));
1273 text_signal.set(format_value(&pattern, None, Some(clamped)));
1274 caret_setter(pos);
1275 merge(Some(clamped), ctx_evt);
1276 ctx_evt.request_frame();
1277 })
1278 }
1279 };
1280
1281 let read_only = self.read_only;
1287 let step_for_key = segment_step.clone();
1288 let stepping_id = ctx.add(ZStack::new().child(sized_field_id).on_key_preview(
1289 move |event, ctx_evt| {
1290 if read_only {
1291 return EventResponse::Ignored;
1292 }
1293 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1294 return EventResponse::Ignored;
1295 };
1296 let mult = if modifiers.shift() { 10 } else { 1 };
1297 let delta = match key {
1298 Key::ArrowUp => mult,
1299 Key::ArrowDown => -mult,
1300 Key::PageUp => 10 * mult,
1301 Key::PageDown => -10 * mult,
1302 _ => return EventResponse::Ignored,
1303 };
1304 step_for_key(delta, ctx_evt);
1305 EventResponse::Handled
1306 },
1307 ));
1308 (stepping_id, field_id)
1312 }
1313}
1314
1315enum DateTimeHalfKind {
1319 Date {
1320 pattern: Rc<ParsedPattern>,
1321 date_signal: Signal<Option<Date>>,
1322 text_signal: Signal<String>,
1323 min: Option<Date>,
1324 max: Option<Date>,
1325 merge: Rc<dyn Fn(Option<Date>, &mut EventContext)>,
1326 },
1327 Time {
1328 pattern: Rc<ParsedPattern>,
1329 time_signal: Signal<Option<Time>>,
1330 text_signal: Signal<String>,
1331 min: Option<Time>,
1332 max: Option<Time>,
1333 merge: Rc<dyn Fn(Option<Time>, &mut EventContext)>,
1334 },
1335}
1336
1337pub(crate) fn rank(fb: &ValidationFeedback) -> u8 {
1340 match fb {
1341 ValidationFeedback::Invalid { .. } => 3,
1342 ValidationFeedback::Corrected { .. } => 2,
1343 ValidationFeedback::Valid => 1,
1344 ValidationFeedback::Pristine => 0,
1345 }
1346}
1347
1348#[cfg(test)]
1353pub(crate) fn compose_feedback(
1354 a: &ValidationFeedback,
1355 b: &ValidationFeedback,
1356) -> ValidationFeedback {
1357 if rank(a) >= rank(b) {
1358 a.clone()
1359 } else {
1360 b.clone()
1361 }
1362}
1363
1364fn middle_dot_icon(size: f32) -> IconWidget {
1368 let mut path = Path::new();
1369 let s = size;
1370 let cx = s * 0.5;
1371 let cy = s * 0.5;
1372 let r = s * 0.10;
1373 path.move_to(Point::new(cx, cy - r));
1377 path.line_to(Point::new(cx + r, cy));
1378 path.line_to(Point::new(cx, cy + r));
1379 path.line_to(Point::new(cx - r, cy));
1380 path.close();
1381 IconWidget::from_path(path, size)
1382}