1#[cfg(test)]
62mod tests;
63
64use std::rc::Rc;
65use teksilo_i18n::localized;
66
67use jiff::civil::Weekday;
68use teksilo_canvas::{Path, Point, Rect, SizeProposal};
69use teksilo_core::accessibility::AccessNodeBuilder;
70use teksilo_core::accesskit::{Action, Role};
71use teksilo_core::build_context::BuildContext;
72use teksilo_core::event::{EventResponse, Key, WidgetEvent};
73use teksilo_core::overlay::{
74 DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
75};
76use teksilo_core::signal::{Prop, Signal};
77use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
78use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
79use teksilo_core::widget_id::WidgetId;
80use teksilo_i18n::resolve_message_widget;
81use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
82
83use crate::calendar::{Calendar, DateRange};
84use crate::common::datetime::Date;
85use crate::common::datetime::pattern::{
86 ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
87 segment_at_position, step_date_field,
88};
89use crate::common::datetime::types::today_local;
90use crate::date_edit::{ValidationBehavior, build_date_validator, calendar_glyph_icon, clamp_date};
91use crate::icon_button::{IconButton, IconButtonSize};
92use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
93use crate::primitives::{
94 Center, FixedSize, HStack, IconWidget, MinSize, Padding, RectWidget, VStack, ZStack,
95};
96use teksilo_i18n::LocalizedString;
97
98type OnRangeChanged = Rc<dyn Fn(Option<DateRange>, &mut EventContext)>;
99
100pub struct DateRangeEdit {
103 value: Signal<Option<DateRange>>,
104 start_part: Signal<Option<Date>>,
107 end_part: Signal<Option<Date>>,
108 start_text: Signal<String>,
109 end_text: Signal<String>,
110 min_date: Option<Date>,
111 max_date: Option<Date>,
112 pattern: Option<String>,
113 placeholder_start: LocalizedString,
114 placeholder_end: LocalizedString,
115 first_day_of_week: Option<Weekday>,
116 enabled: Prop<bool>,
119 read_only: bool,
120 label: Option<LocalizedString>,
121 validation_behavior: ValidationBehavior,
122 end_width_policy: crate::date_edit::WidthPolicy,
129 feedback: Signal<ValidationFeedback>,
131 focused: Signal<bool>,
134 range_popover_open: Signal<bool>,
137 on_value_changed: Option<OnRangeChanged>,
138 style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
139 root_child_id: Option<WidgetId>,
140 tooltip_text: Option<LocalizedString>,
144 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
146 composite_tooltip_content: Option<Box<dyn Widget>>,
148}
149
150impl std::fmt::Debug for DateRangeEdit {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 f.debug_struct("DateRangeEdit").finish_non_exhaustive()
153 }
154}
155
156impl DateRangeEdit {
157 pub fn new(value: Signal<Option<DateRange>>) -> Self {
159 let initial = value.get();
160 let start_part = Signal::new(initial.map(|r| r.start));
161 let end_part = Signal::new(initial.map(|r| r.end));
162 Self {
163 value,
164 start_part,
165 end_part,
166 start_text: Signal::new(String::new()),
167 end_text: Signal::new(String::new()),
168 min_date: None,
169 max_date: None,
170 pattern: None,
171 placeholder_start: LocalizedString::literal(String::new()),
172 placeholder_end: LocalizedString::literal(String::new()),
173 first_day_of_week: None,
174 enabled: Prop::Static(true),
175 read_only: false,
176 label: None,
177 validation_behavior: ValidationBehavior::AutoCorrect,
178 end_width_policy: crate::date_edit::WidthPolicy::Default,
179 feedback: Signal::new(ValidationFeedback::Pristine),
180 focused: Signal::new(false),
181 range_popover_open: Signal::new(false),
182 on_value_changed: None,
183 style_override: None,
184 root_child_id: None,
185 tooltip_text: None,
186 rich_tooltip_source: None,
187 composite_tooltip_content: None,
188 }
189 }
190
191 pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
193 self.style_override = Some(std::rc::Rc::new(style));
194 self
195 }
196
197 pub fn min_date(mut self, d: Date) -> Self {
199 self.min_date = Some(d);
200 self
201 }
202
203 pub fn max_date(mut self, d: Date) -> Self {
205 self.max_date = Some(d);
206 self
207 }
208
209 pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
212 self.pattern = Some(p.into());
213 self
214 }
215
216 pub fn placeholder_start(mut self, text: impl Into<LocalizedString>) -> Self {
218 self.placeholder_start = text.into();
219 self
220 }
221
222 pub fn placeholder_end(mut self, text: impl Into<LocalizedString>) -> Self {
224 self.placeholder_end = text.into();
225 self
226 }
227
228 pub fn first_day_of_week(mut self, w: Weekday) -> Self {
230 self.first_day_of_week = Some(w);
231 self
232 }
233
234 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
237 self.enabled = enabled.into();
238 self
239 }
240
241 pub fn read_only(mut self, read_only: bool) -> Self {
243 self.read_only = read_only;
244 self
245 }
246
247 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
250 let ls: LocalizedString = label.into();
251 self.label = Some(ls);
252 self
253 }
254
255 pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
258 self.validation_behavior = behavior;
259 self
260 }
261
262 pub fn end_width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
269 self.end_width_policy = policy;
270 self
271 }
272
273 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
277 self.tooltip_text = Some(text.into());
278 self.rich_tooltip_source = None;
279 self.composite_tooltip_content = None;
280 self
281 }
282
283 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
287 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
288 self.tooltip_text = None;
289 self.composite_tooltip_content = None;
290 self
291 }
292
293 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
297 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
298 self.tooltip_text = None;
299 self.composite_tooltip_content = None;
300 self
301 }
302
303 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
307 self.composite_tooltip_content = Some(Box::new(content));
308 self.tooltip_text = None;
309 self.rich_tooltip_source = None;
310 self
311 }
312
313 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
316 self.feedback.clone()
317 }
318
319 pub fn on_value_changed(
323 mut self,
324 f: impl Fn(Option<DateRange>, &mut EventContext) + 'static,
325 ) -> Self {
326 self.on_value_changed = Some(Rc::new(f));
327 self
328 }
329
330 pub fn value(&self) -> Signal<Option<DateRange>> {
332 self.value.clone()
333 }
334}
335
336impl Widget for DateRangeEdit {
337 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
338 let theme = ctx.theme_signal().get();
339 use crate::styles::recipe_date_edit_style as de;
340 use crate::styles::recipe_text_input_style as field_dims;
341 let focus_ring_width = theme.shape.focus_ring_width;
342 let self_id = ctx.self_id();
343 ctx.enabled_when(self_id, self.enabled.clone());
345 let read_only = self.read_only;
346
347 ctx.locale_signal().bind_to(
357 ctx.self_id(),
358 ctx.binding_registry(),
359 teksilo_core::binding::BindingLevel::Rebuild,
360 );
361
362 let pattern_string = self.pattern.clone().unwrap_or_else(|| {
363 let tag = ctx.locale_signal().get().unwrap_or_default();
364 crate::common::datetime::format_pattern_for_locale(&tag).to_string()
365 });
366 let parsed_pattern = ParsedPattern::parse(&pattern_string)
367 .unwrap_or_else(|_| ParsedPattern::parse("%Y-%m-%d").unwrap());
368 let pattern_rc = Rc::new(parsed_pattern);
369 let mask_string = mask_for_pattern(&pattern_rc);
370 let min = self.min_date;
371 let max = self.max_date;
372
373 {
379 let start_part = self.start_part.clone();
380 let end_part = self.end_part.clone();
381 let start_text = self.start_text.clone();
382 let end_text = self.end_text.clone();
383 let pattern = pattern_rc.clone();
384 ctx.effect(&self.value, move |new_range| {
385 let (s, e) = match new_range {
386 Some(r) => (Some(r.start), Some(r.end)),
387 None => (None, None),
388 };
389 if start_part.get() != s {
390 start_part.set(s);
391 }
392 if end_part.get() != e {
393 end_part.set(e);
394 }
395 let s_text = s
396 .map(|d| format_value(&pattern, Some(d), None))
397 .unwrap_or_default();
398 let e_text = e
399 .map(|d| format_value(&pattern, Some(d), None))
400 .unwrap_or_default();
401 if start_text.get() != s_text {
402 start_text.set(s_text);
403 }
404 if end_text.get() != e_text {
405 end_text.set(e_text);
406 }
407 });
408 }
409 {
412 self.start_text.set(
413 self.start_part
414 .get()
415 .map(|d| format_value(&pattern_rc, Some(d), None))
416 .unwrap_or_default(),
417 );
418 self.end_text.set(
419 self.end_part
420 .get()
421 .map(|d| format_value(&pattern_rc, Some(d), None))
422 .unwrap_or_default(),
423 );
424 }
425
426 let (start_field_id, start_inner_id) = self.build_half(
429 ctx,
430 HalfKind::Start,
431 pattern_rc.clone(),
432 &mask_string,
433 min,
434 max,
435 );
436 let (end_field_id, end_inner_id) = self.build_half(
437 ctx,
438 HalfKind::End,
439 pattern_rc.clone(),
440 &mask_string,
441 min,
442 max,
443 );
444
445 let separator_icon = arrow_right_icon(field_dims::TEXT_FIELD_HEIGHT * 0.45)
447 .color(teksilo_tokens::TextRole::Secondary);
448 let separator_id = ctx.add(
449 FixedSize::new()
450 .width(field_dims::TEXT_FIELD_HEIGHT * 0.65)
451 .height(field_dims::TEXT_FIELD_HEIGHT)
452 .child(Center::new().child(separator_icon)),
453 );
454
455 let value_for_cal = self.value.clone();
458 let popover_open_for_cal = self.range_popover_open.clone();
459 let mut cal =
460 Calendar::range(value_for_cal.clone()).on_range_changed(move |new_range, ctx_evt| {
461 if new_range.is_some() {
462 popover_open_for_cal.set(false);
463 ctx_evt.dismiss_self_overlay_chain();
464 ctx_evt.request_frame();
465 }
466 });
467 if let Some(min) = self.min_date {
468 cal = cal.min_date(min);
469 }
470 if let Some(max) = self.max_date {
471 cal = cal.max_date(max);
472 }
473 if let Some(fdow) = self.first_day_of_week {
474 cal = cal.first_day_of_week(fdow);
475 }
476 let cal_id = ctx.add_detached_deferred(self.range_popover_open.clone(), cal);
481 ctx.set_dormant(cal_id);
482
483 let popover_open = self.range_popover_open.clone();
484 let self_ref = ctx.self_id();
485 let dismiss_cb: OverlayDismissCallback = {
486 let popover_open = popover_open.clone();
487 Rc::new(move || {
488 popover_open.set(false);
489 })
490 };
491 let trigger_enabled = self.enabled.as_signal().map(move |on| *on && !read_only);
492 let trigger_btn = IconButton::new(calendar_glyph_icon(de::CALENDAR_ICON_SIZE))
493 .embedded()
494 .size(IconButtonSize::Default)
495 .enabled(trigger_enabled)
496 .tooltip(localized(move || {
497 resolve_message_widget("date-range-edit-trigger-tooltip", &[])
498 }))
499 .on_activate_fn(move |ctx_evt: &mut EventContext| {
500 if popover_open.get() {
501 popover_open.set(false);
502 ctx_evt.dismiss_all_except_hosts();
503 } else {
504 popover_open.set(true);
505 ctx_evt.materialize_now(cal_id);
508 ctx_evt.activate(cal_id);
509 ctx_evt.show_overlay(OverlayRequest {
510 content_id: cal_id,
511 anchor: self_ref,
512 placement: OverlayPlacement::BelowPreferred,
513 dismiss: DismissBehavior::EscapeOrClickOutside,
514 layer: OverlayLayer::InTree,
515 parent_overlay: None,
516 on_dismiss: Some(dismiss_cb.clone()),
517 fade_duration: None,
518 });
519 ctx_evt.request_focus(cal_id);
520 }
521 });
522 let trigger_id = ctx.add(trigger_btn);
523
524 let start_shrinkable =
535 ctx.add(crate::primitives::Shrinkable::new().child_id(start_field_id));
536 let end_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child_id(end_field_id));
537 let row = HStack::new()
538 .spacing(0.0)
539 .add_child(start_shrinkable)
540 .add_child(separator_id)
541 .add_child(end_shrinkable)
542 .add_child(trigger_id);
543 let inline_row_id = ctx.add(row);
544 let row_id = ctx.add(
545 Padding::new(
546 0.0,
547 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
548 0.0,
549 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
550 )
551 .child_id(inline_row_id),
552 );
553
554 let feedback_for_border = self.feedback.clone();
562 let focused_for_border = self.focused.clone();
563 let is_disabled = ctx.effective_enabled_signal(self_id).map(|on| !*on);
564 let border_role = focused_for_border
565 .clone()
566 .zip3(&feedback_for_border, &is_disabled)
567 .map(|(focused, fb, disabled)| {
568 if *disabled {
569 return BorderRole::Disabled;
570 }
571 match fb {
572 ValidationFeedback::Invalid { .. } => BorderRole::Error,
573 ValidationFeedback::Corrected { .. } if !*focused => BorderRole::Focused,
574 _ => {
575 if *focused {
576 BorderRole::Focused
577 } else {
578 BorderRole::Field
579 }
580 }
581 }
582 });
583 let border_width_signal =
584 focused_for_border
585 .clone()
586 .zip(&feedback_for_border)
587 .map(move |(focused, fb)| {
588 if *focused || matches!(fb, ValidationFeedback::Invalid { .. }) {
589 focus_ring_width
590 } else {
591 field_dims::TEXT_FIELD_BORDER_WIDTH
592 }
593 });
594 let bg = RectWidget::new()
595 .background(SurfaceRole::Field)
596 .border_color(border_role)
597 .border_width(border_width_signal)
598 .corner_radius(CornerRadius::uniform(field_dims::TEXT_FIELD_CORNER_RADIUS));
599 let bg_id = ctx.add(bg);
600 let framed_id = ctx.add(ZStack::new().add_child(bg_id).add_child(row_id));
601 let sized_id =
602 ctx.add(MinSize::new(0.0, field_dims::TEXT_FIELD_HEIGHT).child_id(framed_id));
603
604 let strip_id = ctx.add(crate::primitives::ValidationStrip::new(
606 self.feedback.clone(),
607 ));
608 ctx.access_described_by(start_inner_id, strip_id);
611 ctx.access_described_by(end_inner_id, strip_id);
612 let framed_in_vstack = ctx.add(
617 crate::primitives::Expand::horizontal()
618 .respect_intrinsic()
619 .child_id(sized_id),
620 );
621 let root_with_strip = ctx.add(
622 VStack::new()
623 .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
624 .add_child(framed_in_vstack)
625 .add_child(strip_id),
626 );
627 let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
628 &self.style_override,
629 ctx,
630 );
631 let cfg = teksilo_core::styles::DateEditStyleConfig {
632 body: root_with_strip,
633 };
634 let root_id = style.make_body(&cfg, ctx);
635 self.root_child_id = Some(root_id);
636
637 if let Some(content) = self.composite_tooltip_content.take() {
639 let delay = ctx.theme().motion.tooltip_delay_heavy;
640 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
641 } else if let Some(source) = self.rich_tooltip_source.clone() {
642 let delay = ctx.theme().motion.tooltip_delay;
643 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
644 } else if let Some(text) = self.tooltip_text.clone() {
645 let delay = ctx.theme().motion.tooltip_delay;
646 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
647 }
648
649 let handlers = HandlerSet::new().focus_within(self.focused.clone());
651 ctx.apply_self_handlers(handlers);
652
653 let self_id = ctx.self_id();
656 self.value.bind_to(
657 self_id,
658 ctx.binding_registry(),
659 teksilo_core::binding::BindingLevel::AccessibilityOnly,
660 );
661 self.feedback.bind_to(
662 self_id,
663 ctx.binding_registry(),
664 teksilo_core::binding::BindingLevel::AccessibilityOnly,
665 );
666 self.range_popover_open.bind_to(
667 self_id,
668 ctx.binding_registry(),
669 teksilo_core::binding::BindingLevel::AccessibilityOnly,
670 );
671 let _ = trigger_id;
674
675 vec![root_with_strip]
676 }
677
678 fn layout_response(
679 &self,
680 proposal: SizeProposal,
681 ctx: &LayoutContext,
682 ) -> teksilo_core::widget::LayoutResponse {
683 let response = match self.root_child_id {
689 Some(id) => ctx
690 .child_layout_response(id, proposal)
691 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
692 None => proposal.resolve(0.0, 0.0).into(),
693 };
694 if self.end_width_policy == crate::date_edit::WidthPolicy::Fill {
695 teksilo_core::widget::LayoutResponse::flexible(response.size, 1.0)
696 } else {
697 response
698 }
699 }
700
701 fn place_children(
702 &self,
703 bounds: Rect,
704 _proposal: SizeProposal,
705 children: &mut [WidgetPlacement],
706 _ctx: &LayoutContext,
707 ) {
708 for child in children.iter_mut() {
709 child.origin = bounds.origin();
710 child.size = bounds.size();
711 }
712 }
713
714 fn children(&self) -> Vec<WidgetId> {
715 self.root_child_id.into_iter().collect()
716 }
717
718 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
719 builder.set_role(Role::DateInput);
720 if let Some(ref label) = self.label {
721 builder.set_name(label.clone());
722 } else {
723 builder.set_name(resolve_message_widget("date-range-edit-name", &[]));
724 }
725 match self.value.get() {
726 Some(r) => {
727 builder.set_value(format!(
728 "{:04}-{:02}-{:02}/{:04}-{:02}-{:02}",
729 r.start.year(),
730 r.start.month(),
731 r.start.day(),
732 r.end.year(),
733 r.end.month(),
734 r.end.day(),
735 ));
736 }
737 None => {
738 builder.set_placeholder(resolve_message_widget("date-range-edit-placeholder", &[]));
739 }
740 }
741 if self.read_only {
743 builder.set_read_only();
744 }
745 if matches!(self.feedback.get(), ValidationFeedback::Invalid { .. }) {
746 builder
747 .inner_mut()
748 .set_invalid(teksilo_core::accesskit::Invalid::True);
749 }
750 builder.add_action(Action::Focus);
751 }
752}
753
754#[derive(Clone, Copy)]
755enum HalfKind {
756 Start,
757 End,
758}
759
760impl DateRangeEdit {
761 #[allow(clippy::too_many_arguments)]
767 fn build_half(
768 &self,
769 ctx: &mut BuildContext,
770 kind: HalfKind,
771 pattern_rc: Rc<ParsedPattern>,
772 mask_string: &str,
773 min: Option<Date>,
774 max: Option<Date>,
775 ) -> (WidgetId, WidgetId) {
776 use crate::styles::recipe_text_input_style as field_dims;
777 let (text_signal, date_signal, placeholder, other_date) = match kind {
778 HalfKind::Start => (
779 self.start_text.clone(),
780 self.start_part.clone(),
781 self.placeholder_start.clone(),
782 self.end_part.clone(),
783 ),
784 HalfKind::End => (
785 self.end_text.clone(),
786 self.end_part.clone(),
787 self.placeholder_end.clone(),
788 self.start_part.clone(),
789 ),
790 };
791
792 let validator =
793 build_date_validator(pattern_rc.clone(), min, max, self.validation_behavior);
794
795 let outer_value = self.value.clone();
796 let on_changed = self.on_value_changed.clone();
797 let merge_into_outer =
798 move |new_d: Option<Date>, other_d: Option<Date>, ctx_evt: &mut EventContext| {
799 let combined = match kind {
800 HalfKind::Start => match (new_d, other_d) {
801 (Some(s), Some(e)) => Some(DateRange::new(s, e)),
802 _ => None,
803 },
804 HalfKind::End => match (other_d, new_d) {
805 (Some(s), Some(e)) => Some(DateRange::new(s, e)),
806 _ => None,
807 },
808 };
809 if outer_value.get() != combined {
810 outer_value.set(combined);
811 if let Some(cb) = on_changed.as_ref() {
812 cb(combined, ctx_evt);
813 }
814 }
815 };
816
817 let commit: Rc<dyn Fn(&mut EventContext)> = {
820 let text_signal = text_signal.clone();
821 let date_signal = date_signal.clone();
822 let other_date = other_date.clone();
823 let pattern = pattern_rc.clone();
824 let merge = merge_into_outer.clone();
825 Rc::new(move |ctx_evt: &mut EventContext| {
826 let raw = text_signal.get();
827 let trimmed = raw.trim();
828 let parsed: Option<Date> = if trimmed.is_empty() {
829 None
830 } else {
831 match parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
832 Some(ParsedValue::Date(d)) => Some(clamp_date(d, min, max)),
833 _ => date_signal.get(),
834 }
835 };
836 if date_signal.get() != parsed {
837 date_signal.set(parsed);
838 }
839 merge(parsed, other_date.get(), ctx_evt);
840 })
841 };
842
843 let inner_height =
844 (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
845 let text_area_height =
846 (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
847
848 let pattern_for_filter = pattern_rc.clone();
849 let mut field = TextInputField::new(text_signal.clone())
850 .enabled(self.enabled.clone())
851 .read_only(self.read_only)
852 .placeholder(placeholder)
853 .text_height(text_area_height)
854 .input_mask(mask_string)
855 .validator({
856 let v = validator.clone();
857 move |s| (v)(s)
858 })
859 .char_filter(move |c: char| {
860 if c.is_ascii_digit() || c == '-' || c == ' ' {
861 return true;
862 }
863 for tok in &pattern_for_filter.tokens {
864 if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
865 && s.chars().any(|x| x == c)
866 {
867 return true;
868 }
869 }
870 false
871 });
872 {
875 let inner_feedback = field.validation_feedback_signal();
876 let composed = self.feedback.clone();
877 let other_feedback_owner = match kind {
878 HalfKind::Start => Some(self.feedback.clone()), HalfKind::End => Some(self.feedback.clone()),
880 };
881 let _ = other_feedback_owner;
887 ctx.effect(&inner_feedback, move |new_fb| {
888 let merged = match (composed.get(), new_fb.clone()) {
889 (a, b) if rank(&a) >= rank(&b) => a,
890 (_, b) => b,
891 };
892 if composed.get() != merged {
893 composed.set(merged);
894 }
895 });
896 }
897 {
898 let commit = commit.clone();
899 field = field.on_submit_fn(move |ctx_evt| commit(ctx_evt));
900 }
901 {
902 let commit = commit.clone();
903 field = field.on_blur_fn(move |ctx_evt| commit(ctx_evt));
904 }
905
906 let caret = field.caret_position();
909 let caret_setter = field.caret_setter();
910
911 let half_label_key = match kind {
913 HalfKind::Start => "date-range-edit-start-name",
914 HalfKind::End => "date-range-edit-end-name",
915 };
916 let field_with_a11y = field
917 .access_role(Role::DateInput)
918 .access_label(resolve_message_widget(half_label_key, &[]));
919 let field_id = ctx.add(field_with_a11y);
920
921 let padded_field_id = ctx.add(
924 Padding::new(
925 field_dims::TEXT_FIELD_PADDING_VERTICAL,
926 4.0,
927 field_dims::TEXT_FIELD_PADDING_VERTICAL,
928 4.0,
929 )
930 .child_id(field_id),
931 );
932 let sized_field_id = match (kind, self.end_width_policy) {
939 (HalfKind::End, crate::date_edit::WidthPolicy::Fill) => {
940 ctx.add(crate::primitives::Expand::horizontal().child_id(padded_field_id))
941 }
942 _ => padded_field_id,
943 };
944
945 let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = {
947 let pattern = pattern_rc.clone();
948 let date_signal = date_signal.clone();
949 let text_signal = text_signal.clone();
950 let other_date = other_date.clone();
951 let merge = merge_into_outer.clone();
952 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
953 let pos = caret.get();
954 let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
955 return;
956 };
957 let current = date_signal.get().unwrap_or_else(today_local);
958 let stepped = step_date_field(current, kind_seg, delta);
959 let clamped = clamp_date(stepped, min, max);
960 date_signal.set(Some(clamped));
961 text_signal.set(format_value(&pattern, Some(clamped), None));
962 caret_setter(pos);
963 merge(Some(clamped), other_date.get(), ctx_evt);
964 ctx_evt.request_frame();
965 })
966 };
967
968 let read_only = self.read_only;
975 let step_for_key = segment_step.clone();
976
977 let stepping_id = ctx.add(ZStack::new().add_child(sized_field_id).on_key_preview(
978 move |event, ctx_evt| {
979 if read_only {
980 return EventResponse::Ignored;
981 }
982 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
983 return EventResponse::Ignored;
984 };
985 let mult = if modifiers.shift() { 10 } else { 1 };
986 let delta = match key {
987 Key::ArrowUp => mult,
988 Key::ArrowDown => -mult,
989 Key::PageUp => 10 * mult,
990 Key::PageDown => -10 * mult,
991 _ => return EventResponse::Ignored,
992 };
993 step_for_key(delta, ctx_evt);
994 EventResponse::Handled
995 },
996 ));
997 (stepping_id, field_id)
1000 }
1001}
1002
1003fn rank(fb: &ValidationFeedback) -> u8 {
1005 match fb {
1006 ValidationFeedback::Invalid { .. } => 3,
1007 ValidationFeedback::Corrected { .. } => 2,
1008 ValidationFeedback::Valid => 1,
1009 ValidationFeedback::Pristine => 0,
1010 }
1011}
1012
1013fn arrow_right_icon(size: f32) -> IconWidget {
1017 let mut path = Path::new();
1018 let s = size;
1019 path.move_to(Point::new(s * 0.35, s * 0.20));
1021 path.line_to(Point::new(s * 0.70, s * 0.50));
1022 path.line_to(Point::new(s * 0.35, s * 0.80));
1023 IconWidget::from_path(path, size)
1024}