1#[cfg(test)]
78mod tests;
79
80use std::rc::Rc;
81use teksilo_i18n::lit;
82use teksilo_i18n::localized;
83
84use jiff::civil::Weekday;
85use teksilo_canvas::{Path, Point, Rect, SizeProposal};
86use teksilo_core::accessibility::AccessNodeBuilder;
87use teksilo_core::accesskit::{Action, Role};
88use teksilo_core::build_context::BuildContext;
89use teksilo_core::event::{EventResponse, Key, WidgetEvent};
90use teksilo_core::overlay::{
91 DismissBehavior, OverlayDismissCallback, OverlayPlacement, OverlayRequest,
92};
93use teksilo_core::signal::{Prop, Signal};
94use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
95use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
96use teksilo_core::widget_id::WidgetId;
97use teksilo_i18n::{LanguageIdentifier, resolve_message_widget};
98use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
99
100use crate::calendar::Calendar;
101use crate::common::datetime::pattern::{
102 ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
103 segment_at_position, step_date_field, step_time_field,
104};
105use crate::common::datetime::types::today_local;
106use crate::common::datetime::written::{date_locale, full_date_time};
107use crate::common::datetime::{Date, DateTime, Time};
108use crate::date_edit::{ValidationBehavior, build_date_validator, calendar_glyph_icon, clamp_date};
109use crate::icon_button::{IconButton, IconButtonSize};
110use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
111use crate::primitives::{
112 Center, FixedSize, HStack, IconWidget, MinSize, Padding, RectWidget, TextWidget, VStack, ZStack,
113};
114use crate::time_edit::{
115 SecondsMode, TimeFormat, build_time_validator, clamp_time, time_pattern_for,
116};
117use teksilo_i18n::LocalizedString;
118
119type OnValueChanged = Rc<dyn Fn(Option<DateTime>, &mut EventContext)>;
120
121pub struct DateTimeEdit {
124 value: Signal<Option<DateTime>>,
125 pub(crate) date_part: Signal<Option<Date>>,
128 pub(crate) time_part: Signal<Option<Time>>,
129 date_text: Signal<String>,
130 time_text: Signal<String>,
131 required_source: Option<Signal<DateTime>>,
134 date_format_pattern: Option<String>,
135 time_format: Option<TimeFormat>,
138 seconds: SecondsMode,
139 min: Option<DateTime>,
140 max: Option<DateTime>,
141 step_minutes: u32,
142 first_day_of_week: Option<Weekday>,
143 show_calendar_button: bool,
144 separator: Option<String>,
148 placeholder: LocalizedString,
149 enabled: Prop<bool>,
152 read_only: bool,
153 label: Option<LocalizedString>,
154 validation_behavior: ValidationBehavior,
155 time_width_policy: crate::date_edit::WidthPolicy,
161 feedback: Signal<ValidationFeedback>,
163 focused: Signal<bool>,
166 calendar_popover_open: Signal<bool>,
169 on_value_changed: Option<OnValueChanged>,
170 style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
171 root_child_id: Option<WidgetId>,
172 calendar_id: Option<WidgetId>,
173 lang: LanguageIdentifier,
177 tooltip_text: Option<LocalizedString>,
181 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
183 composite_tooltip_content: Option<Box<dyn Widget>>,
185}
186
187impl std::fmt::Debug for DateTimeEdit {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.debug_struct("DateTimeEdit").finish_non_exhaustive()
190 }
191}
192
193impl DateTimeEdit {
194 pub fn new(value: Signal<Option<DateTime>>) -> Self {
196 let initial = value.get();
197 let date_part = Signal::new(initial.map(|dt| dt.date()));
198 let time_part = Signal::new(initial.map(|dt| dt.time()));
199 Self {
200 value,
201 date_part,
202 time_part,
203 date_text: Signal::new(String::new()),
204 time_text: Signal::new(String::new()),
205 required_source: None,
206 date_format_pattern: None,
207 time_format: None,
208 seconds: SecondsMode::Hidden,
209 min: None,
210 max: None,
211 step_minutes: 1,
212 first_day_of_week: None,
213 show_calendar_button: true,
214 separator: None,
215 placeholder: LocalizedString::literal(String::new()),
216 enabled: Prop::Static(true),
217 read_only: false,
218 label: None,
219 validation_behavior: ValidationBehavior::AutoCorrect,
220 time_width_policy: crate::date_edit::WidthPolicy::Default,
221 feedback: Signal::new(ValidationFeedback::Pristine),
222 focused: Signal::new(false),
223 calendar_popover_open: Signal::new(false),
224 on_value_changed: None,
225 style_override: None,
226 root_child_id: None,
227 calendar_id: None,
228 lang: date_locale(None),
229 tooltip_text: None,
230 rich_tooltip_source: None,
231 composite_tooltip_content: None,
232 }
233 }
234
235 pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
237 self.style_override = Some(std::rc::Rc::new(style));
238 self
239 }
240
241 pub fn required(value: Signal<DateTime>) -> Self {
245 let proxy: Signal<Option<DateTime>> = Signal::new(Some(value.get()));
246 let mut s = Self::new(proxy);
247 s.required_source = Some(value);
248 s
249 }
250
251 pub fn date_format_pattern(mut self, p: impl Into<String>) -> Self {
254 self.date_format_pattern = Some(p.into());
255 self
256 }
257
258 pub fn time_format(mut self, f: TimeFormat) -> Self {
263 self.time_format = Some(f);
264 self
265 }
266
267 pub fn seconds(mut self, mode: SecondsMode) -> Self {
269 self.seconds = mode;
270 self
271 }
272
273 pub fn min(mut self, dt: DateTime) -> Self {
276 self.min = Some(dt);
277 self
278 }
279
280 pub fn max(mut self, dt: DateTime) -> Self {
283 self.max = Some(dt);
284 self
285 }
286
287 pub fn step_minutes(mut self, n: u32) -> Self {
298 self.step_minutes = n.max(1);
299 self
300 }
301
302 pub fn first_day_of_week(mut self, w: Weekday) -> Self {
304 self.first_day_of_week = Some(w);
305 self
306 }
307
308 pub fn show_calendar_button(mut self, show: bool) -> Self {
310 self.show_calendar_button = show;
311 self
312 }
313
314 pub fn separator(mut self, s: impl Into<String>) -> Self {
318 self.separator = Some(s.into());
319 self
320 }
321
322 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
324 let ls: LocalizedString = text.into();
325 self.placeholder = ls;
326 self
327 }
328
329 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
332 self.enabled = enabled.into();
333 self
334 }
335
336 pub fn read_only(mut self, read_only: bool) -> Self {
338 self.read_only = read_only;
339 self
340 }
341
342 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
345 let ls: LocalizedString = label.into();
346 self.label = Some(ls);
347 self
348 }
349
350 pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
353 self.validation_behavior = behavior;
354 self
355 }
356
357 pub fn time_width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
364 self.time_width_policy = policy;
365 self
366 }
367
368 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
372 self.feedback.clone()
373 }
374
375 pub fn on_value_changed(
378 mut self,
379 f: impl Fn(Option<DateTime>, &mut EventContext) + 'static,
380 ) -> Self {
381 self.on_value_changed = Some(Rc::new(f));
382 self
383 }
384
385 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
390 self.tooltip_text = Some(text.into());
391 self.rich_tooltip_source = None;
392 self.composite_tooltip_content = None;
393 self
394 }
395
396 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
401 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
402 self.tooltip_text = None;
403 self.composite_tooltip_content = None;
404 self
405 }
406
407 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
411 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
412 self.tooltip_text = None;
413 self.composite_tooltip_content = None;
414 self
415 }
416
417 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
422 self.composite_tooltip_content = Some(Box::new(content));
423 self.tooltip_text = None;
424 self.rich_tooltip_source = None;
425 self
426 }
427
428 pub fn value(&self) -> Signal<Option<DateTime>> {
430 self.value.clone()
431 }
432}
433
434impl Widget for DateTimeEdit {
435 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
436 let theme = ctx.theme_signal().get();
437 use crate::styles::recipe_date_edit_style as de;
438 use crate::styles::recipe_text_input_style as field_dims;
439 let focus_ring_width = theme.shape.focus_ring_width;
440 let self_id = ctx.self_id();
441 ctx.enabled_when(self_id, self.enabled.clone());
443 let read_only = self.read_only;
444
445 if let Some(src) = self.required_source.clone() {
447 {
448 let proxy = self.value.clone();
449 ctx.effect(&src, move |new| {
450 if proxy.get() != Some(*new) {
451 proxy.set(Some(*new));
452 }
453 });
454 }
455 {
456 let src_clone = src;
457 ctx.effect(&self.value, move |v| {
458 if let Some(dt) = v
459 && src_clone.get() != *dt
460 {
461 src_clone.set(*dt);
462 }
463 });
464 }
465 }
466
467 ctx.locale_signal().bind_to(
477 ctx.self_id(),
478 ctx.binding_registry(),
479 teksilo_core::binding::BindingLevel::Rebuild,
480 );
481
482 self.lang = date_locale(ctx.locale_signal().get().as_deref());
483 let date_pattern_string = self.date_format_pattern.clone().unwrap_or_else(|| {
484 let tag = ctx.locale_signal().get().unwrap_or_default();
485 crate::common::datetime::format_pattern_for_locale(&tag).to_string()
486 });
487 let date_pattern = ParsedPattern::parse(&date_pattern_string)
488 .unwrap_or_else(|_| ParsedPattern::parse("%Y-%m-%d").unwrap());
489 let date_pattern_rc = Rc::new(date_pattern);
490 let date_mask = mask_for_pattern(&date_pattern_rc);
491
492 let time_format = self.time_format.unwrap_or_else(|| {
493 let tag = ctx.locale_signal().get().unwrap_or_default();
494 if crate::common::datetime::prefers_12_hour_clock(&tag) {
495 TimeFormat::Hour12
496 } else {
497 TimeFormat::Hour24
498 }
499 });
500 let time_pattern_string = time_pattern_for(time_format, self.seconds);
501 let time_pattern = ParsedPattern::parse(&time_pattern_string)
502 .unwrap_or_else(|_| ParsedPattern::parse("%H:%M").unwrap());
503 let time_pattern_rc = Rc::new(time_pattern);
504 let time_mask = mask_for_pattern(&time_pattern_rc);
505
506 let date_min = self.min.map(|dt| dt.date());
507 let date_max = self.max.map(|dt| dt.date());
508 let time_min = self.min.map(|dt| dt.time());
509 let time_max = self.max.map(|dt| dt.time());
510
511 {
517 let date_part = self.date_part.clone();
518 let time_part = self.time_part.clone();
519 let date_text = self.date_text.clone();
520 let time_text = self.time_text.clone();
521 let date_pattern = date_pattern_rc.clone();
522 let time_pattern = time_pattern_rc.clone();
523 ctx.effect(&self.value, move |new_dt| {
524 let new_d = new_dt.map(|dt| dt.date());
525 let new_t = new_dt.map(|dt| dt.time());
526 if date_part.get() != new_d {
527 date_part.set(new_d);
528 }
529 if time_part.get() != new_t {
530 time_part.set(new_t);
531 }
532 let d_text = new_d
533 .map(|d| format_value(&date_pattern, Some(d), None))
534 .unwrap_or_default();
535 let t_text = new_t
536 .map(|t| format_value(&time_pattern, None, Some(t)))
537 .unwrap_or_default();
538 if date_text.get() != d_text {
539 date_text.set(d_text);
540 }
541 if time_text.get() != t_text {
542 time_text.set(t_text);
543 }
544 });
545 }
546 {
549 self.date_text.set(
550 self.date_part
551 .get()
552 .map(|d| format_value(&date_pattern_rc, Some(d), None))
553 .unwrap_or_default(),
554 );
555 self.time_text.set(
556 self.time_part
557 .get()
558 .map(|t| format_value(&time_pattern_rc, None, Some(t)))
559 .unwrap_or_default(),
560 );
561 }
562
563 let (date_field_id, date_inner_id) =
566 self.build_date_half(ctx, date_pattern_rc.clone(), &date_mask, date_min, date_max);
567 let (time_field_id, time_inner_id) =
568 self.build_time_half(ctx, time_pattern_rc.clone(), &time_mask, time_min, time_max);
569
570 let separator_id = match self.separator.as_deref() {
575 None => {
576 let dot = middle_dot_icon(field_dims::TEXT_FIELD_HEIGHT * 0.4)
577 .color(teksilo_tokens::TextRole::Secondary);
578 ctx.add(
579 FixedSize::new()
580 .width(field_dims::TEXT_FIELD_HEIGHT * 0.55)
581 .height(field_dims::TEXT_FIELD_HEIGHT)
582 .child(Center::new().child(dot)),
583 )
584 }
585 Some(s) if s.is_empty() => ctx.add(
586 FixedSize::new()
587 .width(0.0_f32)
588 .height(field_dims::TEXT_FIELD_HEIGHT),
589 ),
590 Some(s) => {
591 let text = TextWidget::new(lit!(s))
592 .style(teksilo_tokens::TextStyleRole::Body)
593 .color(teksilo_tokens::TextRole::Secondary)
594 .single_line()
595 .a11y_hidden();
596 ctx.add(Padding::new(0.0, 6.0, 0.0, 6.0).child(Center::new().child(text)))
597 }
598 };
599
600 let trigger_id_opt = if self.show_calendar_button {
602 let calendar_temp: Signal<Option<Date>> = Signal::new(self.date_part.get());
607 {
608 let temp = calendar_temp.clone();
609 ctx.effect(&self.date_part, move |new_d| {
610 if temp.get() != *new_d {
611 temp.set(*new_d);
612 }
613 });
614 }
615 let popover_open = self.calendar_popover_open.clone();
616 let date_part = self.date_part.clone();
617 let date_text = self.date_text.clone();
618 let date_pattern = date_pattern_rc.clone();
619 let value_outer = self.value.clone();
620 let time_part = self.time_part.clone();
621 let on_changed = self.on_value_changed.clone();
622 let return_focus_to = ctx.self_id();
623 let mut calendar =
624 Calendar::single(calendar_temp.clone()).on_activate(move |d, ctx_evt| {
625 let clamped = clamp_date(d, date_min, date_max);
626 date_part.set(Some(clamped));
627 date_text.set(format_value(&date_pattern, Some(clamped), None));
628 let combined = match (Some(clamped), time_part.get()) {
629 (Some(d), Some(t)) => Some(d.to_datetime(t)),
630 _ => None,
631 };
632 if value_outer.get() != combined {
633 value_outer.set(combined);
634 if let Some(cb) = on_changed.as_ref() {
635 cb(combined, ctx_evt);
636 }
637 }
638 popover_open.set(false);
639 ctx_evt.dismiss_self_overlay_chain();
640 ctx_evt.request_focus(return_focus_to);
641 ctx_evt.request_frame();
642 });
643 if let Some(min) = date_min {
644 calendar = calendar.min_date(min);
645 }
646 if let Some(max) = date_max {
647 calendar = calendar.max_date(max);
648 }
649 if let Some(fdow) = self.first_day_of_week {
650 calendar = calendar.first_day_of_week(fdow);
651 }
652 let opening = calendar.opening();
653 let cal_id = ctx.add_deferred(self.calendar_popover_open.clone(), calendar);
656 ctx.set_dormant(cal_id);
657 self.calendar_id = Some(cal_id);
658
659 let popover_open = self.calendar_popover_open.clone();
660 let self_ref = ctx.self_id();
661 let dismiss_cb: OverlayDismissCallback = {
662 let popover_open = popover_open.clone();
663 Rc::new(move |_, _| {
664 popover_open.set(false);
665 })
666 };
667 let trigger_enabled = self.enabled.as_signal().map(move |on| *on && !read_only);
668 let date_part = self.date_part.clone();
669 let trigger_btn = IconButton::new(calendar_glyph_icon(de::CALENDAR_ICON_SIZE))
670 .embedded()
671 .size(IconButtonSize::Default)
672 .enabled(trigger_enabled)
673 .tooltip(localized(move || {
674 resolve_message_widget("date-time-edit-trigger-tooltip", &[])
675 }))
676 .on_activate_fn(move |ctx_evt: &mut EventContext| {
677 if popover_open.get() {
678 popover_open.set(false);
679 ctx_evt.dismiss_all_except_hosts();
680 } else {
681 opening.open_on(date_part.get());
684 popover_open.set(true);
685 ctx_evt.materialize_now(cal_id);
688 ctx_evt.activate(cal_id);
689 ctx_evt.show_overlay(
690 OverlayRequest::new(
691 cal_id,
692 self_ref,
693 OverlayPlacement::BelowPreferred,
694 DismissBehavior::EscapeOrClickOutside,
695 )
696 .on_dismiss(dismiss_cb.clone()),
697 );
698 ctx_evt.request_focus(cal_id);
699 }
700 });
701 Some(ctx.add(trigger_btn))
702 } else {
703 None
704 };
705
706 let date_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child(date_field_id));
714 let time_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child(time_field_id));
715 let mut row = HStack::new()
716 .spacing(0.0)
717 .child(date_shrinkable)
718 .child(separator_id)
719 .child(time_shrinkable);
720 if let Some(trigger_id) = trigger_id_opt {
721 row = row.child(trigger_id);
722 }
723 let inline_row_id = ctx.add(row);
724 let row_id = ctx.add(
725 Padding::new(
726 0.0,
727 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
728 0.0,
729 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
730 )
731 .child(inline_row_id),
732 );
733
734 let feedback_for_border = self.feedback.clone();
736 let focused_for_border = self.focused.clone();
737 let border_role =
738 focused_for_border
739 .clone()
740 .zip(&feedback_for_border)
741 .map(|(focused, fb)| match fb {
742 ValidationFeedback::Invalid { .. } => BorderRole::Error,
743 ValidationFeedback::Corrected { .. } if !*focused => BorderRole::Focused,
744 _ => {
745 if *focused {
746 BorderRole::Focused
747 } else {
748 BorderRole::Default
749 }
750 }
751 });
752 let border_width_signal =
753 focused_for_border
754 .clone()
755 .zip(&feedback_for_border)
756 .map(move |(focused, fb)| {
757 if *focused || matches!(fb, ValidationFeedback::Invalid { .. }) {
758 focus_ring_width
759 } else {
760 field_dims::TEXT_FIELD_BORDER_WIDTH
761 }
762 });
763 let bg = RectWidget::new()
764 .background(SurfaceRole::Content)
765 .border_color(border_role)
766 .border_width(border_width_signal)
767 .corner_radius(CornerRadius::uniform(field_dims::TEXT_FIELD_CORNER_RADIUS));
768 let bg_id = ctx.add(bg);
769 let framed_id = ctx.add(ZStack::new().child(bg_id).child(row_id));
770 let sized_id = ctx.add(
771 MinSize::new(
772 0.0,
773 crate::styles::TextInputRecipe::for_tokens(&ctx.theme().input).height,
774 )
775 .child(framed_id),
776 );
777
778 let strip_id = ctx.add(crate::primitives::ValidationStrip::new(
780 self.feedback.clone(),
781 ));
782 ctx.access_described_by(date_inner_id, strip_id);
787 ctx.access_described_by(time_inner_id, strip_id);
788 let framed_in_vstack = ctx.add(
795 crate::primitives::Expand::horizontal()
796 .respect_intrinsic()
797 .child(sized_id),
798 );
799 let root_with_strip = ctx.add(
800 VStack::new()
801 .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
802 .child(framed_in_vstack)
803 .child(strip_id),
804 );
805 let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
806 &self.style_override,
807 ctx,
808 );
809 let cfg = teksilo_core::styles::DateEditStyleConfig {
810 body: root_with_strip,
811 };
812 let root_id = style.make_body(&cfg, ctx);
813 self.root_child_id = Some(root_id);
814
815 if let Some(content) = self.composite_tooltip_content.take() {
817 let delay = ctx.theme().motion.tooltip_delay_heavy;
818 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
819 } else if let Some(source) = self.rich_tooltip_source.clone() {
820 let delay = ctx.theme().motion.tooltip_delay;
821 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
822 } else if let Some(text) = self.tooltip_text.clone() {
823 let delay = ctx.theme().motion.tooltip_delay;
824 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
825 }
826
827 let handlers = HandlerSet::new().focus_within(self.focused.clone());
829 ctx.apply_self_handlers(handlers);
830
831 let self_id = ctx.self_id();
835 self.value.bind_to(
836 self_id,
837 ctx.binding_registry(),
838 teksilo_core::binding::BindingLevel::AccessibilityOnly,
839 );
840 self.feedback.bind_to(
841 self_id,
842 ctx.binding_registry(),
843 teksilo_core::binding::BindingLevel::AccessibilityOnly,
844 );
845 self.calendar_popover_open.bind_to(
846 self_id,
847 ctx.binding_registry(),
848 teksilo_core::binding::BindingLevel::AccessibilityOnly,
849 );
850
851 let mut out = vec![root_with_strip];
857 if let Some(cal_id) = self.calendar_id {
858 out.push(cal_id);
859 }
860 out
861 }
862
863 fn layout_response(
864 &self,
865 proposal: SizeProposal,
866 ctx: &LayoutContext,
867 ) -> teksilo_core::widget::LayoutResponse {
868 let response = match self.root_child_id {
873 Some(id) => ctx
874 .child_layout_response(id, proposal)
875 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
876 None => proposal.resolve(0.0, 0.0).into(),
877 };
878 if self.time_width_policy == crate::date_edit::WidthPolicy::Fill {
879 teksilo_core::widget::LayoutResponse::flexible(response.size, 1.0)
880 } else {
881 response
882 }
883 }
884
885 fn place_children(
886 &self,
887 bounds: Rect,
888 _proposal: SizeProposal,
889 children: &mut [WidgetPlacement],
890 _ctx: &LayoutContext,
891 ) {
892 for child in children.iter_mut() {
896 if Some(child.id) == self.calendar_id {
897 child.size = teksilo_canvas::Size::ZERO;
898 continue;
899 }
900 child.origin = bounds.origin();
901 child.size = bounds.size();
902 }
903 }
904
905 fn children(&self) -> Vec<WidgetId> {
906 let mut out = Vec::new();
907 if let Some(id) = self.root_child_id {
908 out.push(id);
909 }
910 if let Some(id) = self.calendar_id {
911 out.push(id);
912 }
913 out
914 }
915
916 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
917 builder.set_role(Role::DateTimeInput);
918 if let Some(ref label) = self.label {
919 builder.set_name(label.clone());
920 } else {
921 builder.set_name(resolve_message_widget("date-time-edit-name", &[]));
922 }
923 match self.value.get() {
924 Some(dt) => {
925 let seconds = matches!(self.seconds, SecondsMode::Editable);
926 builder.set_value(full_date_time(dt, seconds, &self.lang));
927 }
928 None => {
929 if !self.placeholder.resolve_now().is_empty() {
930 builder.set_placeholder(self.placeholder.resolve_now());
931 } else {
932 builder
933 .set_placeholder(resolve_message_widget("date-time-edit-placeholder", &[]));
934 }
935 }
936 }
937 if self.read_only {
939 builder.set_read_only();
940 }
941 if matches!(self.feedback.get(), ValidationFeedback::Invalid { .. }) {
942 builder
943 .inner_mut()
944 .set_invalid(teksilo_core::accesskit::Invalid::True);
945 }
946 if self.show_calendar_button {
947 builder
948 .inner_mut()
949 .set_has_popup(teksilo_core::accesskit::HasPopup::Grid);
950 builder.set_expanded(self.calendar_popover_open.get());
951 }
952 builder.add_action(Action::Focus);
953 }
954}
955
956impl DateTimeEdit {
957 fn build_date_half(
963 &self,
964 ctx: &mut BuildContext,
965 pattern_rc: Rc<ParsedPattern>,
966 mask_string: &str,
967 min: Option<Date>,
968 max: Option<Date>,
969 ) -> (WidgetId, WidgetId) {
970 let validator =
971 build_date_validator(pattern_rc.clone(), min, max, self.validation_behavior);
972
973 let outer_value = self.value.clone();
974 let on_changed = self.on_value_changed.clone();
975 let time_part = self.time_part.clone();
976 let merge_into_outer = move |new_d: Option<Date>, ctx_evt: &mut EventContext| {
977 let combined = match (new_d, time_part.get()) {
978 (Some(d), Some(t)) => Some(d.to_datetime(t)),
979 _ => None,
980 };
981 if outer_value.get() != combined {
982 outer_value.set(combined);
983 if let Some(cb) = on_changed.as_ref() {
984 cb(combined, ctx_evt);
985 }
986 }
987 };
988
989 let date_signal = self.date_part.clone();
990 let text_signal = self.date_text.clone();
991
992 let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
994 let text_signal = text_signal.clone();
995 let date_signal = date_signal.clone();
996 let pattern = pattern_rc.clone();
997 let merge = merge_into_outer.clone();
998 Rc::new(move |ctx_evt: &mut EventContext| {
999 let raw = text_signal.get();
1000 let trimmed = raw.trim();
1001 let (parsed, accepted): (Option<Date>, bool) = if trimmed.is_empty() {
1002 (None, true)
1003 } else {
1004 match parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
1005 Some(ParsedValue::Date(d)) => (Some(clamp_date(d, min, max)), true),
1006 _ => (date_signal.get(), false),
1007 }
1008 };
1009 if date_signal.get() != parsed {
1010 date_signal.set(parsed);
1011 }
1012 merge(parsed, ctx_evt);
1013 accepted
1014 })
1015 };
1016
1017 self.build_field(
1018 ctx,
1019 text_signal.clone(),
1020 mask_string,
1021 validator,
1022 self.placeholder.clone(),
1023 commit,
1024 "date-time-edit-date-name",
1025 DateTimeHalfKind::Date {
1026 pattern: pattern_rc,
1027 date_signal,
1028 text_signal,
1029 min,
1030 max,
1031 merge: Rc::new(merge_into_outer),
1032 },
1033 )
1034 }
1035
1036 fn build_time_half(
1037 &self,
1038 ctx: &mut BuildContext,
1039 pattern_rc: Rc<ParsedPattern>,
1040 mask_string: &str,
1041 min: Option<Time>,
1042 max: Option<Time>,
1043 ) -> (WidgetId, WidgetId) {
1044 let validator =
1045 build_time_validator(pattern_rc.clone(), min, max, self.validation_behavior);
1046
1047 let outer_value = self.value.clone();
1048 let on_changed = self.on_value_changed.clone();
1049 let date_part = self.date_part.clone();
1050 let merge_into_outer = move |new_t: Option<Time>, ctx_evt: &mut EventContext| {
1051 let combined = match (date_part.get(), new_t) {
1052 (Some(d), Some(t)) => Some(d.to_datetime(t)),
1053 _ => None,
1054 };
1055 if outer_value.get() != combined {
1056 outer_value.set(combined);
1057 if let Some(cb) = on_changed.as_ref() {
1058 cb(combined, ctx_evt);
1059 }
1060 }
1061 };
1062
1063 let time_signal = self.time_part.clone();
1064 let text_signal = self.time_text.clone();
1065
1066 let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
1068 let text_signal = text_signal.clone();
1069 let time_signal = time_signal.clone();
1070 let pattern = pattern_rc.clone();
1071 let merge = merge_into_outer.clone();
1072 Rc::new(move |ctx_evt: &mut EventContext| {
1073 let raw = text_signal.get();
1074 let trimmed = raw.trim();
1075 let (parsed, accepted): (Option<Time>, bool) = if trimmed.is_empty() {
1076 (None, true)
1077 } else {
1078 match parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
1079 Some(ParsedValue::Time(t)) => (Some(clamp_time(t, min, max)), true),
1080 _ => (time_signal.get(), false),
1081 }
1082 };
1083 if time_signal.get() != parsed {
1084 time_signal.set(parsed);
1085 }
1086 merge(parsed, ctx_evt);
1087 accepted
1088 })
1089 };
1090
1091 self.build_field(
1092 ctx,
1093 text_signal.clone(),
1094 mask_string,
1095 validator,
1096 LocalizedString::literal(String::new()),
1097 commit,
1098 "date-time-edit-time-name",
1099 DateTimeHalfKind::Time {
1100 pattern: pattern_rc,
1101 time_signal,
1102 text_signal,
1103 min,
1104 max,
1105 merge: Rc::new(merge_into_outer),
1106 },
1107 )
1108 }
1109
1110 #[allow(clippy::too_many_arguments)]
1115 fn build_field(
1116 &self,
1117 ctx: &mut BuildContext,
1118 text_signal: Signal<String>,
1119 mask_string: &str,
1120 validator: crate::primitives::text_input_field::ValidatorFn,
1121 placeholder: LocalizedString,
1122 commit: Rc<dyn Fn(&mut EventContext) -> bool>,
1123 a11y_label_key: &str,
1124 kind: DateTimeHalfKind,
1125 ) -> (WidgetId, WidgetId) {
1126 use crate::styles::recipe_text_input_style as field_dims;
1127 let inner_height =
1128 (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
1129 let text_area_height =
1130 (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
1131
1132 let pattern_for_filter = match &kind {
1133 DateTimeHalfKind::Date { pattern, .. } => pattern.clone(),
1134 DateTimeHalfKind::Time { pattern, .. } => pattern.clone(),
1135 };
1136 let is_time_half = matches!(kind, DateTimeHalfKind::Time { .. });
1137 let mut field = TextInputField::new(text_signal.clone())
1138 .enabled(self.enabled.clone())
1139 .on_access_set_value({
1144 let text_signal = text_signal.clone();
1145 let commit = commit.clone();
1146 move |text: &str, ctx: &mut EventContext| {
1151 text_signal.set(text.to_string());
1152 commit(ctx)
1153 }
1154 })
1155 .read_only(self.read_only)
1156 .placeholder(placeholder)
1157 .text_height(text_area_height)
1158 .input_mask(mask_string)
1159 .validator({
1160 let v = validator.clone();
1161 move |s| (v)(s)
1162 })
1163 .char_filter(move |c: char| {
1164 if c.is_ascii_digit() || c == ' ' || c == ':' || c == '-' {
1165 return true;
1166 }
1167 if is_time_half && matches!(c, 'a' | 'A' | 'p' | 'P' | 'm' | 'M') {
1168 return true;
1169 }
1170 for tok in &pattern_for_filter.tokens {
1171 if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
1172 && s.chars().any(|x| x == c)
1173 {
1174 return true;
1175 }
1176 }
1177 false
1178 });
1179 {
1183 let inner_feedback = field.validation_feedback_signal();
1184 let composed = self.feedback.clone();
1185 ctx.effect(&inner_feedback, move |new_fb| {
1186 let merged = match (composed.get(), new_fb.clone()) {
1187 (a, b) if rank(&a) >= rank(&b) => a,
1188 (_, b) => b,
1189 };
1190 if composed.get() != merged {
1191 composed.set(merged);
1192 }
1193 });
1194 }
1195 {
1196 let commit = commit.clone();
1197 field = field.on_submit_fn(move |ctx_evt| {
1198 commit(ctx_evt);
1199 });
1200 }
1201 {
1202 let commit = commit.clone();
1203 field = field.on_blur_fn(move |ctx_evt| {
1204 commit(ctx_evt);
1205 });
1206 }
1207
1208 let caret = field.caret_position();
1209 let caret_setter = field.caret_setter();
1210
1211 let field_with_a11y = field.access_label(resolve_message_widget(a11y_label_key, &[]));
1215 let field_id = ctx.add(field_with_a11y);
1216
1217 let padded_field_id = ctx.add(
1218 Padding::new(
1219 field_dims::TEXT_FIELD_PADDING_VERTICAL,
1220 4.0,
1221 field_dims::TEXT_FIELD_PADDING_VERTICAL,
1222 4.0,
1223 )
1224 .child(field_id),
1225 );
1226 let is_time = matches!(kind, DateTimeHalfKind::Time { .. });
1232 let sized_field_id =
1233 if is_time && self.time_width_policy == crate::date_edit::WidthPolicy::Fill {
1234 ctx.add(crate::primitives::Expand::horizontal().child(padded_field_id))
1235 } else {
1236 padded_field_id
1237 };
1238
1239 let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = match kind {
1242 DateTimeHalfKind::Date {
1243 pattern,
1244 date_signal,
1245 text_signal,
1246 min,
1247 max,
1248 merge,
1249 } => {
1250 let caret = caret.clone();
1251 let caret_setter = caret_setter.clone();
1252 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
1253 let pos = caret.get();
1254 let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
1255 return;
1256 };
1257 let current = date_signal.get().unwrap_or_else(today_local);
1258 let stepped = step_date_field(current, kind_seg, delta);
1259 let clamped = clamp_date(stepped, min, max);
1260 date_signal.set(Some(clamped));
1261 text_signal.set(format_value(&pattern, Some(clamped), None));
1262 caret_setter(pos);
1263 merge(Some(clamped), ctx_evt);
1264 ctx_evt.request_frame();
1265 })
1266 }
1267 DateTimeHalfKind::Time {
1268 pattern,
1269 time_signal,
1270 text_signal,
1271 min,
1272 max,
1273 merge,
1274 } => {
1275 let caret = caret.clone();
1276 let caret_setter = caret_setter.clone();
1277 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
1278 let pos = caret.get();
1279 let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
1280 return;
1281 };
1282 let current = time_signal.get().unwrap_or_else(Time::midnight);
1283 let stepped = step_time_field(current, kind_seg, delta);
1284 let clamped = clamp_time(stepped, min, max);
1285 time_signal.set(Some(clamped));
1286 text_signal.set(format_value(&pattern, None, Some(clamped)));
1287 caret_setter(pos);
1288 merge(Some(clamped), ctx_evt);
1289 ctx_evt.request_frame();
1290 })
1291 }
1292 };
1293
1294 let read_only = self.read_only;
1300 let step_for_key = segment_step.clone();
1301 let stepping_id = ctx.add(ZStack::new().child(sized_field_id).on_key_preview(
1302 move |event, ctx_evt| {
1303 if read_only {
1304 return EventResponse::Ignored;
1305 }
1306 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1307 return EventResponse::Ignored;
1308 };
1309 let mult = if modifiers.shift() { 10 } else { 1 };
1310 let delta = match key {
1311 Key::ArrowUp => mult,
1312 Key::ArrowDown => -mult,
1313 Key::PageUp => 10 * mult,
1314 Key::PageDown => -10 * mult,
1315 _ => return EventResponse::Ignored,
1316 };
1317 step_for_key(delta, ctx_evt);
1318 EventResponse::Handled
1319 },
1320 ));
1321 (stepping_id, field_id)
1325 }
1326}
1327
1328enum DateTimeHalfKind {
1332 Date {
1333 pattern: Rc<ParsedPattern>,
1334 date_signal: Signal<Option<Date>>,
1335 text_signal: Signal<String>,
1336 min: Option<Date>,
1337 max: Option<Date>,
1338 merge: Rc<dyn Fn(Option<Date>, &mut EventContext)>,
1339 },
1340 Time {
1341 pattern: Rc<ParsedPattern>,
1342 time_signal: Signal<Option<Time>>,
1343 text_signal: Signal<String>,
1344 min: Option<Time>,
1345 max: Option<Time>,
1346 merge: Rc<dyn Fn(Option<Time>, &mut EventContext)>,
1347 },
1348}
1349
1350pub(crate) fn rank(fb: &ValidationFeedback) -> u8 {
1353 match fb {
1354 ValidationFeedback::Invalid { .. } => 3,
1355 ValidationFeedback::Corrected { .. } => 2,
1356 ValidationFeedback::Valid => 1,
1357 ValidationFeedback::Pristine => 0,
1358 }
1359}
1360
1361#[cfg(test)]
1366pub(crate) fn compose_feedback(
1367 a: &ValidationFeedback,
1368 b: &ValidationFeedback,
1369) -> ValidationFeedback {
1370 if rank(a) >= rank(b) {
1371 a.clone()
1372 } else {
1373 b.clone()
1374 }
1375}
1376
1377fn middle_dot_icon(size: f32) -> IconWidget {
1381 let mut path = Path::new();
1382 let s = size;
1383 let cx = s * 0.5;
1384 let cy = s * 0.5;
1385 let r = s * 0.10;
1386 path.move_to(Point::new(cx, cy - r));
1390 path.line_to(Point::new(cx + r, cy));
1391 path.line_to(Point::new(cx, cy + r));
1392 path.line_to(Point::new(cx - r, cy));
1393 path.close();
1394 IconWidget::from_path(path, size)
1395}