1#[cfg(test)]
73mod tests;
74
75use std::rc::Rc;
76use teksilo_i18n::localized;
77
78use jiff::civil::Weekday;
79use teksilo_canvas::{Path, Point, Rect, SizeProposal};
80use teksilo_core::accessibility::AccessNodeBuilder;
81use teksilo_core::accesskit::{Action, Role};
82use teksilo_core::build_context::BuildContext;
83use teksilo_core::event::{EventResponse, Key, WidgetEvent};
84use teksilo_core::overlay::{
85 DismissBehavior, OverlayDismissCallback, OverlayPlacement, OverlayRequest,
86};
87use teksilo_core::signal::{Prop, Signal};
88use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
89use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
90use teksilo_core::widget_id::WidgetId;
91use teksilo_i18n::{FluentValue, LanguageIdentifier, resolve_message_widget};
92use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
93
94use crate::calendar::{Calendar, DateRange};
95use crate::common::datetime::Date;
96use crate::common::datetime::pattern::{
97 ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
98 segment_at_position, step_date_field,
99};
100use crate::common::datetime::types::today_local;
101use crate::common::datetime::written::{date_locale, full_date};
102use crate::date_edit::{ValidationBehavior, build_date_validator, calendar_glyph_icon, clamp_date};
103use crate::icon_button::{IconButton, IconButtonSize};
104use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
105use crate::primitives::{
106 Center, FixedSize, HStack, IconWidget, MinSize, Padding, RectWidget, VStack, ZStack,
107};
108use teksilo_i18n::LocalizedString;
109
110type OnRangeChanged = Rc<dyn Fn(Option<DateRange>, &mut EventContext)>;
111
112pub struct DateRangeEdit {
115 value: Signal<Option<DateRange>>,
116 start_part: Signal<Option<Date>>,
119 end_part: Signal<Option<Date>>,
120 start_text: Signal<String>,
121 end_text: Signal<String>,
122 min_date: Option<Date>,
123 max_date: Option<Date>,
124 pattern: Option<String>,
125 placeholder_start: LocalizedString,
126 placeholder_end: LocalizedString,
127 first_day_of_week: Option<Weekday>,
128 enabled: Prop<bool>,
131 read_only: bool,
132 label: Option<LocalizedString>,
133 validation_behavior: ValidationBehavior,
134 end_width_policy: crate::date_edit::WidthPolicy,
141 feedback: Signal<ValidationFeedback>,
143 focused: Signal<bool>,
146 range_popover_open: Signal<bool>,
149 on_value_changed: Option<OnRangeChanged>,
150 style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
151 root_child_id: Option<WidgetId>,
152 lang: LanguageIdentifier,
155 tooltip_text: Option<LocalizedString>,
159 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
161 composite_tooltip_content: Option<Box<dyn Widget>>,
163}
164
165impl std::fmt::Debug for DateRangeEdit {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.debug_struct("DateRangeEdit").finish_non_exhaustive()
168 }
169}
170
171impl DateRangeEdit {
172 pub fn new(value: Signal<Option<DateRange>>) -> Self {
174 let initial = value.get();
175 let start_part = Signal::new(initial.map(|r| r.start));
176 let end_part = Signal::new(initial.map(|r| r.end));
177 Self {
178 value,
179 start_part,
180 end_part,
181 start_text: Signal::new(String::new()),
182 end_text: Signal::new(String::new()),
183 min_date: None,
184 max_date: None,
185 pattern: None,
186 placeholder_start: LocalizedString::literal(String::new()),
187 placeholder_end: LocalizedString::literal(String::new()),
188 first_day_of_week: None,
189 enabled: Prop::Static(true),
190 read_only: false,
191 label: None,
192 validation_behavior: ValidationBehavior::AutoCorrect,
193 end_width_policy: crate::date_edit::WidthPolicy::Default,
194 feedback: Signal::new(ValidationFeedback::Pristine),
195 focused: Signal::new(false),
196 range_popover_open: Signal::new(false),
197 on_value_changed: None,
198 style_override: None,
199 root_child_id: None,
200 lang: date_locale(None),
201 tooltip_text: None,
202 rich_tooltip_source: None,
203 composite_tooltip_content: None,
204 }
205 }
206
207 pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
209 self.style_override = Some(std::rc::Rc::new(style));
210 self
211 }
212
213 pub fn min_date(mut self, d: Date) -> Self {
215 self.min_date = Some(d);
216 self
217 }
218
219 pub fn max_date(mut self, d: Date) -> Self {
221 self.max_date = Some(d);
222 self
223 }
224
225 pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
228 self.pattern = Some(p.into());
229 self
230 }
231
232 pub fn placeholder_start(mut self, text: impl Into<LocalizedString>) -> Self {
234 self.placeholder_start = text.into();
235 self
236 }
237
238 pub fn placeholder_end(mut self, text: impl Into<LocalizedString>) -> Self {
240 self.placeholder_end = text.into();
241 self
242 }
243
244 pub fn first_day_of_week(mut self, w: Weekday) -> Self {
246 self.first_day_of_week = Some(w);
247 self
248 }
249
250 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
253 self.enabled = enabled.into();
254 self
255 }
256
257 pub fn read_only(mut self, read_only: bool) -> Self {
259 self.read_only = read_only;
260 self
261 }
262
263 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
266 let ls: LocalizedString = label.into();
267 self.label = Some(ls);
268 self
269 }
270
271 pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
274 self.validation_behavior = behavior;
275 self
276 }
277
278 pub fn end_width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
285 self.end_width_policy = policy;
286 self
287 }
288
289 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
293 self.tooltip_text = Some(text.into());
294 self.rich_tooltip_source = None;
295 self.composite_tooltip_content = None;
296 self
297 }
298
299 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
303 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
304 self.tooltip_text = None;
305 self.composite_tooltip_content = None;
306 self
307 }
308
309 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
313 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
314 self.tooltip_text = None;
315 self.composite_tooltip_content = None;
316 self
317 }
318
319 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
323 self.composite_tooltip_content = Some(Box::new(content));
324 self.tooltip_text = None;
325 self.rich_tooltip_source = None;
326 self
327 }
328
329 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
332 self.feedback.clone()
333 }
334
335 pub fn on_value_changed(
339 mut self,
340 f: impl Fn(Option<DateRange>, &mut EventContext) + 'static,
341 ) -> Self {
342 self.on_value_changed = Some(Rc::new(f));
343 self
344 }
345
346 pub fn value(&self) -> Signal<Option<DateRange>> {
348 self.value.clone()
349 }
350}
351
352impl Widget for DateRangeEdit {
353 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
354 let theme = ctx.theme_signal().get();
355 use crate::styles::recipe_date_edit_style as de;
356 use crate::styles::recipe_text_input_style as field_dims;
357 let focus_ring_width = theme.shape.focus_ring_width;
358 let self_id = ctx.self_id();
359 ctx.enabled_when(self_id, self.enabled.clone());
361 let read_only = self.read_only;
362
363 ctx.locale_signal().bind_to(
373 ctx.self_id(),
374 ctx.binding_registry(),
375 teksilo_core::binding::BindingLevel::Rebuild,
376 );
377
378 self.lang = date_locale(ctx.locale_signal().get().as_deref());
379 let pattern_string = self.pattern.clone().unwrap_or_else(|| {
380 let tag = ctx.locale_signal().get().unwrap_or_default();
381 crate::common::datetime::format_pattern_for_locale(&tag).to_string()
382 });
383 let parsed_pattern = ParsedPattern::parse(&pattern_string)
384 .unwrap_or_else(|_| ParsedPattern::parse("%Y-%m-%d").unwrap());
385 let pattern_rc = Rc::new(parsed_pattern);
386 let mask_string = mask_for_pattern(&pattern_rc);
387 let min = self.min_date;
388 let max = self.max_date;
389
390 {
396 let start_part = self.start_part.clone();
397 let end_part = self.end_part.clone();
398 let start_text = self.start_text.clone();
399 let end_text = self.end_text.clone();
400 let pattern = pattern_rc.clone();
401 ctx.effect(&self.value, move |new_range| {
402 let (s, e) = match new_range {
403 Some(r) => (Some(r.start), Some(r.end)),
404 None => (None, None),
405 };
406 if start_part.get() != s {
407 start_part.set(s);
408 }
409 if end_part.get() != e {
410 end_part.set(e);
411 }
412 let s_text = s
413 .map(|d| format_value(&pattern, Some(d), None))
414 .unwrap_or_default();
415 let e_text = e
416 .map(|d| format_value(&pattern, Some(d), None))
417 .unwrap_or_default();
418 if start_text.get() != s_text {
419 start_text.set(s_text);
420 }
421 if end_text.get() != e_text {
422 end_text.set(e_text);
423 }
424 });
425 }
426 {
429 self.start_text.set(
430 self.start_part
431 .get()
432 .map(|d| format_value(&pattern_rc, Some(d), None))
433 .unwrap_or_default(),
434 );
435 self.end_text.set(
436 self.end_part
437 .get()
438 .map(|d| format_value(&pattern_rc, Some(d), None))
439 .unwrap_or_default(),
440 );
441 }
442
443 let (start_field_id, start_inner_id) = self.build_half(
446 ctx,
447 HalfKind::Start,
448 pattern_rc.clone(),
449 &mask_string,
450 min,
451 max,
452 );
453 let (end_field_id, end_inner_id) = self.build_half(
454 ctx,
455 HalfKind::End,
456 pattern_rc.clone(),
457 &mask_string,
458 min,
459 max,
460 );
461
462 let separator_icon = arrow_right_icon(field_dims::TEXT_FIELD_HEIGHT * 0.45)
464 .color(teksilo_tokens::TextRole::Secondary);
465 let separator_id = ctx.add(
466 FixedSize::new()
467 .width(field_dims::TEXT_FIELD_HEIGHT * 0.65)
468 .height(field_dims::TEXT_FIELD_HEIGHT)
469 .child(Center::new().child(separator_icon)),
470 );
471
472 let value_for_cal = self.value.clone();
475 let popover_open_for_cal = self.range_popover_open.clone();
476 let mut cal =
477 Calendar::range(value_for_cal.clone()).on_range_changed(move |new_range, ctx_evt| {
478 if new_range.is_some() {
479 popover_open_for_cal.set(false);
480 ctx_evt.dismiss_self_overlay_chain();
481 ctx_evt.request_frame();
482 }
483 });
484 if let Some(min) = self.min_date {
485 cal = cal.min_date(min);
486 }
487 if let Some(max) = self.max_date {
488 cal = cal.max_date(max);
489 }
490 if let Some(fdow) = self.first_day_of_week {
491 cal = cal.first_day_of_week(fdow);
492 }
493 let opening = cal.opening();
494 let cal_id = ctx.add_detached_deferred(self.range_popover_open.clone(), cal);
499 ctx.set_dormant(cal_id);
500
501 let popover_open = self.range_popover_open.clone();
502 let self_ref = ctx.self_id();
503 let dismiss_cb: OverlayDismissCallback = {
504 let popover_open = popover_open.clone();
505 Rc::new(move |_, _| {
506 popover_open.set(false);
507 })
508 };
509 let trigger_enabled = self.enabled.as_signal().map(move |on| *on && !read_only);
510 let range = self.value.clone();
511 let trigger_btn = IconButton::new(calendar_glyph_icon(de::CALENDAR_ICON_SIZE))
512 .embedded()
513 .size(IconButtonSize::Default)
514 .enabled(trigger_enabled)
515 .tooltip(localized(move || {
516 resolve_message_widget("date-range-edit-trigger-tooltip", &[])
517 }))
518 .on_activate_fn(move |ctx_evt: &mut EventContext| {
519 if popover_open.get() {
520 popover_open.set(false);
521 ctx_evt.dismiss_all_except_hosts();
522 } else {
523 opening.open_on(range.get().map(|r| r.start));
526 popover_open.set(true);
527 ctx_evt.materialize_now(cal_id);
530 ctx_evt.activate(cal_id);
531 ctx_evt.show_overlay(
532 OverlayRequest::new(
533 cal_id,
534 self_ref,
535 OverlayPlacement::BelowPreferred,
536 DismissBehavior::EscapeOrClickOutside,
537 )
538 .on_dismiss(dismiss_cb.clone()),
539 );
540 ctx_evt.request_focus(cal_id);
541 }
542 });
543 let trigger_id = ctx.add(trigger_btn);
544
545 let start_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child(start_field_id));
556 let end_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child(end_field_id));
557 let row = HStack::new()
558 .spacing(0.0)
559 .child(start_shrinkable)
560 .child(separator_id)
561 .child(end_shrinkable)
562 .child(trigger_id);
563 let inline_row_id = ctx.add(row);
564 let row_id = ctx.add(
565 Padding::new(
566 0.0,
567 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
568 0.0,
569 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
570 )
571 .child(inline_row_id),
572 );
573
574 let feedback_for_border = self.feedback.clone();
582 let focused_for_border = self.focused.clone();
583 let is_disabled = ctx.effective_enabled_signal(self_id).map(|on| !*on);
584 let border_role = focused_for_border
585 .clone()
586 .zip3(&feedback_for_border, &is_disabled)
587 .map(|(focused, fb, disabled)| {
588 if *disabled {
589 return BorderRole::Disabled;
590 }
591 match fb {
592 ValidationFeedback::Invalid { .. } => BorderRole::Error,
593 ValidationFeedback::Corrected { .. } if !*focused => BorderRole::Focused,
594 _ => {
595 if *focused {
596 BorderRole::Focused
597 } else {
598 BorderRole::Field
599 }
600 }
601 }
602 });
603 let border_width_signal =
604 focused_for_border
605 .clone()
606 .zip(&feedback_for_border)
607 .map(move |(focused, fb)| {
608 if *focused || matches!(fb, ValidationFeedback::Invalid { .. }) {
609 focus_ring_width
610 } else {
611 field_dims::TEXT_FIELD_BORDER_WIDTH
612 }
613 });
614 let bg = RectWidget::new()
615 .background(SurfaceRole::Field)
616 .border_color(border_role)
617 .border_width(border_width_signal)
618 .corner_radius(CornerRadius::uniform(field_dims::TEXT_FIELD_CORNER_RADIUS));
619 let bg_id = ctx.add(bg);
620 let framed_id = ctx.add(ZStack::new().child(bg_id).child(row_id));
621 let sized_id = ctx.add(
622 MinSize::new(
623 0.0,
624 crate::styles::TextInputRecipe::for_tokens(&ctx.theme().input).height,
625 )
626 .child(framed_id),
627 );
628
629 let strip_id = ctx.add(crate::primitives::ValidationStrip::new(
631 self.feedback.clone(),
632 ));
633 ctx.access_described_by(start_inner_id, strip_id);
638 ctx.access_described_by(end_inner_id, strip_id);
639 let framed_in_vstack = ctx.add(
644 crate::primitives::Expand::horizontal()
645 .respect_intrinsic()
646 .child(sized_id),
647 );
648 let root_with_strip = ctx.add(
649 VStack::new()
650 .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
651 .child(framed_in_vstack)
652 .child(strip_id),
653 );
654 let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
655 &self.style_override,
656 ctx,
657 );
658 let cfg = teksilo_core::styles::DateEditStyleConfig {
659 body: root_with_strip,
660 };
661 let root_id = style.make_body(&cfg, ctx);
662 self.root_child_id = Some(root_id);
663
664 if let Some(content) = self.composite_tooltip_content.take() {
666 let delay = ctx.theme().motion.tooltip_delay_heavy;
667 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
668 } else if let Some(source) = self.rich_tooltip_source.clone() {
669 let delay = ctx.theme().motion.tooltip_delay;
670 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
671 } else if let Some(text) = self.tooltip_text.clone() {
672 let delay = ctx.theme().motion.tooltip_delay;
673 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
674 }
675
676 let handlers = HandlerSet::new().focus_within(self.focused.clone());
678 ctx.apply_self_handlers(handlers);
679
680 let self_id = ctx.self_id();
683 self.value.bind_to(
684 self_id,
685 ctx.binding_registry(),
686 teksilo_core::binding::BindingLevel::AccessibilityOnly,
687 );
688 self.feedback.bind_to(
689 self_id,
690 ctx.binding_registry(),
691 teksilo_core::binding::BindingLevel::AccessibilityOnly,
692 );
693 self.range_popover_open.bind_to(
694 self_id,
695 ctx.binding_registry(),
696 teksilo_core::binding::BindingLevel::AccessibilityOnly,
697 );
698 let _ = trigger_id;
702
703 vec![root_with_strip]
704 }
705
706 fn layout_response(
707 &self,
708 proposal: SizeProposal,
709 ctx: &LayoutContext,
710 ) -> teksilo_core::widget::LayoutResponse {
711 let response = match self.root_child_id {
717 Some(id) => ctx
718 .child_layout_response(id, proposal)
719 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
720 None => proposal.resolve(0.0, 0.0).into(),
721 };
722 if self.end_width_policy == crate::date_edit::WidthPolicy::Fill {
723 teksilo_core::widget::LayoutResponse::flexible(response.size, 1.0)
724 } else {
725 response
726 }
727 }
728
729 fn place_children(
730 &self,
731 bounds: Rect,
732 _proposal: SizeProposal,
733 children: &mut [WidgetPlacement],
734 _ctx: &LayoutContext,
735 ) {
736 for child in children.iter_mut() {
737 child.origin = bounds.origin();
738 child.size = bounds.size();
739 }
740 }
741
742 fn children(&self) -> Vec<WidgetId> {
743 self.root_child_id.into_iter().collect()
744 }
745
746 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
747 builder.set_role(Role::DateInput);
748 if let Some(ref label) = self.label {
749 builder.set_name(label.clone());
750 } else {
751 builder.set_name(resolve_message_widget("date-range-edit-name", &[]));
752 }
753 match self.value.get() {
754 Some(r) => {
755 builder.set_value(resolve_message_widget(
756 "calendar-date-range",
757 &[
758 ("start", FluentValue::from(full_date(r.start, &self.lang))),
759 ("end", FluentValue::from(full_date(r.end, &self.lang))),
760 ],
761 ));
762 }
763 None => {
764 builder.set_placeholder(resolve_message_widget("date-range-edit-placeholder", &[]));
765 }
766 }
767 if self.read_only {
769 builder.set_read_only();
770 }
771 if matches!(self.feedback.get(), ValidationFeedback::Invalid { .. }) {
772 builder
773 .inner_mut()
774 .set_invalid(teksilo_core::accesskit::Invalid::True);
775 }
776 builder.add_action(Action::Focus);
777 }
778}
779
780#[derive(Clone, Copy)]
781enum HalfKind {
782 Start,
783 End,
784}
785
786impl DateRangeEdit {
787 #[allow(clippy::too_many_arguments)]
794 fn build_half(
795 &self,
796 ctx: &mut BuildContext,
797 kind: HalfKind,
798 pattern_rc: Rc<ParsedPattern>,
799 mask_string: &str,
800 min: Option<Date>,
801 max: Option<Date>,
802 ) -> (WidgetId, WidgetId) {
803 use crate::styles::recipe_text_input_style as field_dims;
804 let (text_signal, date_signal, placeholder, other_date) = match kind {
805 HalfKind::Start => (
806 self.start_text.clone(),
807 self.start_part.clone(),
808 self.placeholder_start.clone(),
809 self.end_part.clone(),
810 ),
811 HalfKind::End => (
812 self.end_text.clone(),
813 self.end_part.clone(),
814 self.placeholder_end.clone(),
815 self.start_part.clone(),
816 ),
817 };
818
819 let validator =
820 build_date_validator(pattern_rc.clone(), min, max, self.validation_behavior);
821
822 let outer_value = self.value.clone();
823 let on_changed = self.on_value_changed.clone();
824 let merge_into_outer =
825 move |new_d: Option<Date>, other_d: Option<Date>, ctx_evt: &mut EventContext| {
826 let combined = match kind {
827 HalfKind::Start => match (new_d, other_d) {
828 (Some(s), Some(e)) => Some(DateRange::new(s, e)),
829 _ => None,
830 },
831 HalfKind::End => match (other_d, new_d) {
832 (Some(s), Some(e)) => Some(DateRange::new(s, e)),
833 _ => None,
834 },
835 };
836 if outer_value.get() != combined {
837 outer_value.set(combined);
838 if let Some(cb) = on_changed.as_ref() {
839 cb(combined, ctx_evt);
840 }
841 }
842 };
843
844 let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
848 let text_signal = text_signal.clone();
849 let date_signal = date_signal.clone();
850 let other_date = other_date.clone();
851 let pattern = pattern_rc.clone();
852 let merge = merge_into_outer.clone();
853 Rc::new(move |ctx_evt: &mut EventContext| {
854 let raw = text_signal.get();
855 let trimmed = raw.trim();
856 let (parsed, accepted): (Option<Date>, bool) = if trimmed.is_empty() {
857 (None, true)
858 } else {
859 match parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
860 Some(ParsedValue::Date(d)) => (Some(clamp_date(d, min, max)), true),
861 _ => (date_signal.get(), false),
862 }
863 };
864 if date_signal.get() != parsed {
865 date_signal.set(parsed);
866 }
867 merge(parsed, other_date.get(), ctx_evt);
868 accepted
869 })
870 };
871
872 let inner_height =
873 (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
874 let text_area_height =
875 (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
876
877 let pattern_for_filter = pattern_rc.clone();
878 let mut field = TextInputField::new(text_signal.clone())
879 .enabled(self.enabled.clone())
880 .on_access_set_value({
885 let text_signal = text_signal.clone();
886 let commit = commit.clone();
887 move |text: &str, ctx: &mut EventContext| {
892 text_signal.set(text.to_string());
893 commit(ctx)
894 }
895 })
896 .read_only(self.read_only)
897 .placeholder(placeholder)
898 .text_height(text_area_height)
899 .input_mask(mask_string)
900 .validator({
901 let v = validator.clone();
902 move |s| (v)(s)
903 })
904 .char_filter(move |c: char| {
905 if c.is_ascii_digit() || c == '-' || c == ' ' {
906 return true;
907 }
908 for tok in &pattern_for_filter.tokens {
909 if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
910 && s.chars().any(|x| x == c)
911 {
912 return true;
913 }
914 }
915 false
916 });
917 {
920 let inner_feedback = field.validation_feedback_signal();
921 let composed = self.feedback.clone();
922 let other_feedback_owner = match kind {
923 HalfKind::Start => Some(self.feedback.clone()), HalfKind::End => Some(self.feedback.clone()),
925 };
926 let _ = other_feedback_owner;
932 ctx.effect(&inner_feedback, move |new_fb| {
933 let merged = match (composed.get(), new_fb.clone()) {
934 (a, b) if rank(&a) >= rank(&b) => a,
935 (_, b) => b,
936 };
937 if composed.get() != merged {
938 composed.set(merged);
939 }
940 });
941 }
942 {
943 let commit = commit.clone();
944 field = field.on_submit_fn(move |ctx_evt| {
945 commit(ctx_evt);
946 });
947 }
948 {
949 let commit = commit.clone();
950 field = field.on_blur_fn(move |ctx_evt| {
951 commit(ctx_evt);
952 });
953 }
954
955 let caret = field.caret_position();
958 let caret_setter = field.caret_setter();
959
960 let half_label_key = match kind {
965 HalfKind::Start => "date-range-edit-start-name",
966 HalfKind::End => "date-range-edit-end-name",
967 };
968 let field_with_a11y = field.access_label(resolve_message_widget(half_label_key, &[]));
969 let field_id = ctx.add(field_with_a11y);
970
971 let padded_field_id = ctx.add(
974 Padding::new(
975 field_dims::TEXT_FIELD_PADDING_VERTICAL,
976 4.0,
977 field_dims::TEXT_FIELD_PADDING_VERTICAL,
978 4.0,
979 )
980 .child(field_id),
981 );
982 let sized_field_id = match (kind, self.end_width_policy) {
989 (HalfKind::End, crate::date_edit::WidthPolicy::Fill) => {
990 ctx.add(crate::primitives::Expand::horizontal().child(padded_field_id))
991 }
992 _ => padded_field_id,
993 };
994
995 let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = {
997 let pattern = pattern_rc.clone();
998 let date_signal = date_signal.clone();
999 let text_signal = text_signal.clone();
1000 let other_date = other_date.clone();
1001 let merge = merge_into_outer.clone();
1002 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
1003 let pos = caret.get();
1004 let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
1005 return;
1006 };
1007 let current = date_signal.get().unwrap_or_else(today_local);
1008 let stepped = step_date_field(current, kind_seg, delta);
1009 let clamped = clamp_date(stepped, min, max);
1010 date_signal.set(Some(clamped));
1011 text_signal.set(format_value(&pattern, Some(clamped), None));
1012 caret_setter(pos);
1013 merge(Some(clamped), other_date.get(), ctx_evt);
1014 ctx_evt.request_frame();
1015 })
1016 };
1017
1018 let read_only = self.read_only;
1025 let step_for_key = segment_step.clone();
1026
1027 let stepping_id = ctx.add(ZStack::new().child(sized_field_id).on_key_preview(
1028 move |event, ctx_evt| {
1029 if read_only {
1030 return EventResponse::Ignored;
1031 }
1032 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1033 return EventResponse::Ignored;
1034 };
1035 let mult = if modifiers.shift() { 10 } else { 1 };
1036 let delta = match key {
1037 Key::ArrowUp => mult,
1038 Key::ArrowDown => -mult,
1039 Key::PageUp => 10 * mult,
1040 Key::PageDown => -10 * mult,
1041 _ => return EventResponse::Ignored,
1042 };
1043 step_for_key(delta, ctx_evt);
1044 EventResponse::Handled
1045 },
1046 ));
1047 (stepping_id, field_id)
1050 }
1051}
1052
1053fn rank(fb: &ValidationFeedback) -> u8 {
1055 match fb {
1056 ValidationFeedback::Invalid { .. } => 3,
1057 ValidationFeedback::Corrected { .. } => 2,
1058 ValidationFeedback::Valid => 1,
1059 ValidationFeedback::Pristine => 0,
1060 }
1061}
1062
1063fn arrow_right_icon(size: f32) -> IconWidget {
1067 let mut path = Path::new();
1068 let s = size;
1069 path.move_to(Point::new(s * 0.35, s * 0.20));
1071 path.line_to(Point::new(s * 0.70, s * 0.50));
1072 path.line_to(Point::new(s * 0.35, s * 0.80));
1073 IconWidget::from_path(path, size)
1074}