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