1#[cfg(test)]
51mod tests;
52
53use std::rc::Rc;
54
55use teksilo_canvas::{Rect, SizeProposal};
56use teksilo_core::accessibility::AccessNodeBuilder;
57use teksilo_core::accesskit::{Action, Role};
58use teksilo_core::build_context::BuildContext;
59use teksilo_core::event::{EventResponse, Key, WidgetEvent};
60use teksilo_core::signal::{Prop, Signal};
61use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
62use teksilo_core::widget_builder::HandlerSet;
63use teksilo_core::widget_id::WidgetId;
64use teksilo_i18n::{localized, resolve_message_widget};
65
66use crate::common::datetime::Time;
67use crate::common::datetime::pattern::{
68 ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
69 segment_at_position, step_time_field,
70};
71use crate::date_edit::ValidationBehavior;
72use crate::primitives::text_input_field::{ValidationFeedback, ValidationOutcome};
73use crate::text_input::TextInput;
74use teksilo_i18n::LocalizedString;
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum TimeFormat {
81 #[default]
83 Hour24,
84 Hour12,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum SecondsMode {
91 #[default]
93 Hidden,
94 Editable,
96}
97
98type OnValueChanged = Rc<dyn Fn(Option<Time>, &mut EventContext)>;
99
100pub struct TimeEdit {
105 value: Signal<Option<Time>>,
106 required_source: Option<Signal<Time>>,
109 format: Option<TimeFormat>,
113 seconds: SecondsMode,
114 pattern_override: Option<String>,
115 min_time: Option<Time>,
116 max_time: Option<Time>,
117 step_minutes: u32,
118 placeholder: LocalizedString,
119 enabled: Prop<bool>,
122 read_only: bool,
123 validation_behavior: ValidationBehavior,
124 width_policy: crate::date_edit::WidthPolicy,
125 label: Option<LocalizedString>,
126 on_value_changed: Option<OnValueChanged>,
127 text_signal: Signal<String>,
128 focused: Signal<bool>,
129 feedback: Signal<ValidationFeedback>,
130 style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
131 root_child_id: Option<WidgetId>,
132 tooltip_text: Option<LocalizedString>,
136 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
138 composite_tooltip_content: Option<Box<dyn Widget>>,
140}
141
142impl std::fmt::Debug for TimeEdit {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct("TimeEdit")
145 .field("format", &self.format)
146 .field("seconds", &self.seconds)
147 .finish_non_exhaustive()
148 }
149}
150
151impl TimeEdit {
152 pub fn new(value: Signal<Option<Time>>) -> Self {
154 Self {
155 value,
156 required_source: None,
157 format: None,
158 seconds: SecondsMode::Hidden,
159 pattern_override: None,
160 min_time: None,
161 max_time: None,
162 step_minutes: 1,
163 placeholder: LocalizedString::literal(String::new()),
164 enabled: Prop::Static(true),
165 read_only: false,
166 validation_behavior: ValidationBehavior::AutoCorrect,
167 width_policy: crate::date_edit::WidthPolicy::Default,
168 label: None,
169 on_value_changed: None,
170 text_signal: Signal::new(String::new()),
171 focused: Signal::new(false),
172 feedback: Signal::new(ValidationFeedback::Pristine),
173 style_override: None,
174 root_child_id: None,
175 tooltip_text: None,
176 rich_tooltip_source: None,
177 composite_tooltip_content: None,
178 }
179 }
180
181 pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
183 self.style_override = Some(std::rc::Rc::new(style));
184 self
185 }
186
187 pub fn required(value: Signal<Time>) -> Self {
190 let proxy: Signal<Option<Time>> = Signal::new(Some(value.get()));
191 let mut s = Self::new(proxy);
192 s.required_source = Some(value);
193 s
194 }
195
196 pub fn format(mut self, f: TimeFormat) -> Self {
201 self.format = Some(f);
202 self
203 }
204
205 pub fn seconds(mut self, mode: SecondsMode) -> Self {
207 self.seconds = mode;
208 self
209 }
210
211 pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
214 self.pattern_override = Some(p.into());
215 self
216 }
217
218 pub fn min_time(mut self, t: Time) -> Self {
220 self.min_time = Some(t);
221 self
222 }
223
224 pub fn max_time(mut self, t: Time) -> Self {
226 self.max_time = Some(t);
227 self
228 }
229
230 pub fn step_minutes(mut self, n: u32) -> Self {
239 self.step_minutes = n.max(1);
240 self
241 }
242
243 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
245 let ls: LocalizedString = text.into();
246 self.placeholder = ls;
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 validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
266 self.validation_behavior = behavior;
267 self
268 }
269
270 pub fn width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
274 self.width_policy = policy;
275 self
276 }
277
278 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
280 self.feedback.clone()
281 }
282
283 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
285 let ls: LocalizedString = label.into();
286 self.label = Some(ls);
287 self
288 }
289
290 pub fn on_value_changed(
293 mut self,
294 f: impl Fn(Option<Time>, &mut EventContext) + 'static,
295 ) -> Self {
296 self.on_value_changed = Some(Rc::new(f));
297 self
298 }
299
300 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
303 self.tooltip_text = Some(text.into());
304 self.rich_tooltip_source = None;
305 self.composite_tooltip_content = None;
306 self
307 }
308
309 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
312 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
313 self.tooltip_text = None;
314 self.composite_tooltip_content = None;
315 self
316 }
317
318 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
321 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
322 self.tooltip_text = None;
323 self.composite_tooltip_content = None;
324 self
325 }
326
327 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
330 self.composite_tooltip_content = Some(Box::new(content));
331 self.tooltip_text = None;
332 self.rich_tooltip_source = None;
333 self
334 }
335
336 pub fn value(&self) -> Signal<Option<Time>> {
338 self.value.clone()
339 }
340
341 fn resolved_pattern(&self, format: TimeFormat) -> String {
342 if let Some(p) = self.pattern_override.clone() {
343 return p;
344 }
345 time_pattern_for(format, self.seconds)
346 }
347}
348
349pub(crate) fn time_pattern_for(format: TimeFormat, seconds: SecondsMode) -> String {
353 match (format, seconds) {
354 (TimeFormat::Hour24, SecondsMode::Hidden) => "%H:%M".into(),
355 (TimeFormat::Hour24, SecondsMode::Editable) => "%H:%M:%S".into(),
356 (TimeFormat::Hour12, SecondsMode::Hidden) => "%I:%M %p".into(),
357 (TimeFormat::Hour12, SecondsMode::Editable) => "%I:%M:%S %p".into(),
358 }
359}
360
361impl Widget for TimeEdit {
362 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
363 if let Some(src) = self.required_source.clone() {
367 {
368 let proxy = self.value.clone();
369 ctx.effect(&src, move |new| {
370 if proxy.get() != Some(*new) {
371 proxy.set(Some(*new));
372 }
373 });
374 }
375 {
376 let src_clone = src;
377 ctx.effect(&self.value, move |v| {
378 if let Some(t) = v
379 && src_clone.get() != *t
380 {
381 src_clone.set(*t);
382 }
383 });
384 }
385 }
386
387 let self_id = ctx.self_id();
388 ctx.enabled_when(self_id, self.enabled.clone());
390 let enabled = self.enabled.clone();
391 let read_only = self.read_only;
392
393 ctx.locale_signal().bind_to(
403 ctx.self_id(),
404 ctx.binding_registry(),
405 teksilo_core::binding::BindingLevel::Rebuild,
406 );
407
408 let format = self.format.unwrap_or_else(|| {
409 let tag = ctx.locale_signal().get().unwrap_or_default();
410 if crate::common::datetime::prefers_12_hour_clock(&tag) {
411 TimeFormat::Hour12
412 } else {
413 TimeFormat::Hour24
414 }
415 });
416 let pattern_string = self.resolved_pattern(format);
417 let parsed_pattern = ParsedPattern::parse(&pattern_string)
418 .unwrap_or_else(|_| ParsedPattern::parse("%H:%M").unwrap());
419 let pattern_rc = Rc::new(parsed_pattern);
420 let on_value_changed = self.on_value_changed.clone();
421 let min = self.min_time;
422 let max = self.max_time;
423 let step_minutes = self.step_minutes as i64;
424
425 {
427 let init = match self.value.get() {
428 Some(t) => format_value(&pattern_rc, None, Some(t)),
429 None => String::new(),
430 };
431 self.text_signal.set(init);
432 }
433
434 {
436 let text_signal = self.text_signal.clone();
437 let focused = self.focused.clone();
438 let pattern = pattern_rc.clone();
439 ctx.effect(&self.value, move |new_value| {
440 if focused.get() {
441 return;
442 }
443 let formatted = match new_value {
444 Some(t) => format_value(&pattern, None, Some(*t)),
445 None => String::new(),
446 };
447 if text_signal.get() != formatted {
448 text_signal.set(formatted);
449 }
450 });
451 }
452
453 let validation_behavior = self.validation_behavior;
458 let validator: crate::primitives::text_input_field::ValidatorFn = {
459 let pattern = pattern_rc.clone();
460 Rc::new(move |raw: &str| -> ValidationOutcome {
461 let trimmed = raw.trim();
462 if trimmed.is_empty() {
463 return ValidationOutcome::Valid;
464 }
465 if let Some(ParsedValue::Time(t)) =
466 parse_value(&pattern, trimmed, ParseTarget::TimeOnly)
467 {
468 let clamped = clamp_time(t, min, max);
469 let formatted = format_value(&pattern, None, Some(clamped));
470 if formatted == trimmed && clamped == t {
471 return ValidationOutcome::Valid;
472 }
473 return ValidationOutcome::Corrected {
474 corrected: formatted.clone(),
475 message: localized(move || {
476 resolve_message_widget(
477 "validation-corrected-to",
478 &[("value", formatted.clone().into())],
479 )
480 }),
481 };
482 }
483 if validation_behavior == ValidationBehavior::AutoCorrect
484 && let Some((corrected, msg)) =
485 try_clamp_time_recovery(&pattern, trimmed, min, max)
486 {
487 return ValidationOutcome::Corrected {
488 corrected,
489 message: msg,
490 };
491 }
492 ValidationOutcome::Invalid {
493 message: localized(move || {
494 resolve_message_widget("time-edit-validation-not-a-time", &[])
495 }),
496 }
497 })
498 };
499
500 let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
504 let value_signal = self.value.clone();
505 let text_signal = self.text_signal.clone();
506 let feedback_signal = self.feedback.clone();
507 let pattern = pattern_rc.clone();
508 let on_value_changed = on_value_changed.clone();
509 Rc::new(move |ctx_evt: &mut EventContext| {
510 if matches!(feedback_signal.get(), ValidationFeedback::Invalid { .. }) {
511 return false;
512 }
513 let raw = text_signal.get();
514 let trimmed = raw.trim();
515 let (new_value, accepted): (Option<Time>, bool) = if trimmed.is_empty() {
516 (None, true)
517 } else {
518 match parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
519 Some(ParsedValue::Time(t)) => (Some(clamp_time(t, min, max)), true),
520 _ => (value_signal.get(), false),
521 }
522 };
523 if value_signal.get() != new_value {
524 value_signal.set(new_value);
525 if let Some(cb) = on_value_changed.as_ref() {
526 cb(new_value, ctx_evt);
527 }
528 }
529 accepted
530 })
531 };
532
533 let _ = step_minutes;
541
542 let pattern_for_filter = pattern_rc.clone();
550 let mask_string = mask_for_pattern(&pattern_rc);
551 let mut text_input = TextInput::new(self.text_signal.clone())
552 .placeholder(self.placeholder.clone())
553 .on_access_set_value({
558 let text_signal = self.text_signal.clone();
559 let commit = commit.clone();
560 move |text: &str, ctx: &mut EventContext| {
565 text_signal.set(text.to_string());
566 commit(ctx)
567 }
568 })
569 .enabled(enabled)
570 .read_only(read_only)
571 .input_mask(mask_string)
572 .validator({
573 let v = validator.clone();
574 move |s| (v)(s)
575 })
576 .char_filter(move |c: char| {
577 if c.is_ascii_digit() || c == ' ' || c == ':' {
578 return true;
579 }
580 if matches!(c, 'a' | 'A' | 'p' | 'P' | 'm' | 'M') {
581 return true;
582 }
583 for tok in &pattern_for_filter.tokens {
584 if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
585 && s.chars().any(|x| x == c)
586 {
587 return true;
588 }
589 }
590 false
591 })
592 .on_submit_fn({
593 let commit = commit.clone();
594 move |ctx_evt| {
595 commit(ctx_evt);
596 }
597 })
598 .on_blur_fn({
599 let commit = commit.clone();
600 move |ctx_evt| {
601 commit(ctx_evt);
602 }
603 });
604 if let Some(label) = self.label.clone() {
605 text_input = text_input.label(label);
606 }
607
608 let caret_for_step = text_input.caret_position();
609 let caret_setter_for_step = text_input.caret_setter();
610
611 {
612 let inner_feedback = text_input.validation_feedback_signal();
613 let outer_feedback = self.feedback.clone();
614 ctx.effect(&inner_feedback, move |fb| {
615 if outer_feedback.get() != *fb {
616 outer_feedback.set(fb.clone());
617 }
618 });
619 }
620
621 let body_id = match self.width_policy {
626 crate::date_edit::WidthPolicy::Default => ctx.add(text_input),
627 crate::date_edit::WidthPolicy::Fill => {
628 let inner_id = ctx.add(text_input);
629 ctx.add(
630 crate::primitives::Expand::horizontal()
631 .respect_intrinsic()
632 .child(inner_id),
633 )
634 }
635 };
636 let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
637 &self.style_override,
638 ctx,
639 );
640 let cfg = teksilo_core::styles::DateEditStyleConfig { body: body_id };
641 let root_id = style.make_body(&cfg, ctx);
642 self.root_child_id = Some(root_id);
643
644 if let Some(content) = self.composite_tooltip_content.take() {
646 let delay = ctx.theme().motion.tooltip_delay_heavy;
647 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
648 } else if let Some(source) = self.rich_tooltip_source.clone() {
649 let delay = ctx.theme().motion.tooltip_delay;
650 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
651 } else if let Some(text) = self.tooltip_text.clone() {
652 let delay = ctx.theme().motion.tooltip_delay;
653 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
654 }
655
656 let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = {
658 let pattern_for_step = pattern_rc.clone();
659 let value_for_step = self.value.clone();
660 let text_for_step = self.text_signal.clone();
661 let on_changed_for_step = self.on_value_changed.clone();
662 let min_for_step = self.min_time;
663 let max_for_step = self.max_time;
664 let caret_for_step = caret_for_step.clone();
665 let caret_setter = caret_setter_for_step.clone();
666 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
667 let caret = caret_for_step.get();
668 let Some((_, _, kind)) = segment_at_position(&pattern_for_step, caret) else {
669 return;
670 };
671 let current = value_for_step.get().unwrap_or_else(Time::midnight);
672 let stepped = step_time_field(current, kind, delta);
673 let clamped = clamp_time(stepped, min_for_step, max_for_step);
674 value_for_step.set(Some(clamped));
675 text_for_step.set(format_value(&pattern_for_step, None, Some(clamped)));
676 caret_setter(caret);
679 if let Some(cb) = on_changed_for_step.as_ref() {
680 cb(Some(clamped), ctx_evt);
681 }
682 ctx_evt.request_frame();
683 })
684 };
685
686 let step_for_key = segment_step.clone();
692 let handlers = HandlerSet::new()
693 .focus_within(self.focused.clone())
694 .on_key_preview(move |event, ctx_evt| {
695 if read_only {
701 return EventResponse::Ignored;
702 }
703 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
704 return EventResponse::Ignored;
705 };
706 let mult = if modifiers.shift() { 10 } else { 1 };
707 let delta = match key {
708 Key::ArrowUp => mult,
709 Key::ArrowDown => -mult,
710 Key::PageUp => 10 * mult,
711 Key::PageDown => -10 * mult,
712 _ => return EventResponse::Ignored,
713 };
714 step_for_key(delta, ctx_evt);
715 EventResponse::Handled
716 });
717 ctx.apply_self_handlers(handlers);
718
719 let self_id = ctx.self_id();
722 self.value.bind_to(
723 self_id,
724 ctx.binding_registry(),
725 teksilo_core::binding::BindingLevel::AccessibilityOnly,
726 );
727
728 vec![root_id]
729 }
730
731 fn layout_response(
732 &self,
733 proposal: SizeProposal,
734 ctx: &LayoutContext,
735 ) -> teksilo_core::widget::LayoutResponse {
736 match self.root_child_id {
740 Some(id) => ctx
741 .child_layout_response(id, proposal)
742 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
743 None => proposal.resolve(0.0, 0.0).into(),
744 }
745 }
746
747 fn place_children(
748 &self,
749 bounds: Rect,
750 _proposal: SizeProposal,
751 children: &mut [WidgetPlacement],
752 _ctx: &LayoutContext,
753 ) {
754 for child in children.iter_mut() {
755 child.origin = bounds.origin();
756 child.size = bounds.size();
757 }
758 }
759
760 fn children(&self) -> Vec<WidgetId> {
761 self.root_child_id.into_iter().collect()
762 }
763
764 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
765 builder.set_role(Role::TimeInput);
766 if let Some(ref label) = self.label {
767 builder.set_name(label.resolve_now());
768 } else {
769 builder.set_name(resolve_message_widget("time-edit-name", &[]));
770 }
771 match self.value.get() {
772 Some(t) => {
773 builder.set_value(format!(
774 "{:02}:{:02}:{:02}",
775 t.hour(),
776 t.minute(),
777 t.second()
778 ));
779 }
780 None => {
781 if !self.placeholder.resolve_now().is_empty() {
782 builder.set_placeholder(self.placeholder.resolve_now());
783 } else {
784 builder.set_placeholder(resolve_message_widget("time-edit-placeholder", &[]));
785 }
786 }
787 }
788 if self.read_only {
790 builder.set_read_only();
791 }
792 builder.add_action(Action::Focus);
793 }
797}
798
799pub(crate) fn clamp_time(t: Time, min: Option<Time>, max: Option<Time>) -> Time {
800 let t = match min {
801 Some(min) if t < min => min,
802 _ => t,
803 };
804 match max {
805 Some(max) if t > max => max,
806 _ => t,
807 }
808}
809
810pub(crate) fn build_time_validator(
817 pattern: Rc<ParsedPattern>,
818 min: Option<Time>,
819 max: Option<Time>,
820 behavior: ValidationBehavior,
821) -> crate::primitives::text_input_field::ValidatorFn {
822 Rc::new(move |raw: &str| -> ValidationOutcome {
823 let trimmed = raw.trim();
824 if trimmed.is_empty() {
825 return ValidationOutcome::Valid;
826 }
827 if let Some(ParsedValue::Time(t)) = parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
828 let clamped = clamp_time(t, min, max);
829 let formatted = format_value(&pattern, None, Some(clamped));
830 if formatted == trimmed && clamped == t {
831 return ValidationOutcome::Valid;
832 }
833 return ValidationOutcome::Corrected {
834 corrected: formatted.clone(),
835 message: localized(move || {
836 resolve_message_widget(
837 "validation-corrected-to",
838 &[("value", formatted.clone().into())],
839 )
840 }),
841 };
842 }
843 if behavior == ValidationBehavior::AutoCorrect
844 && let Some((corrected, msg)) = try_clamp_time_recovery(&pattern, trimmed, min, max)
845 {
846 return ValidationOutcome::Corrected {
847 corrected,
848 message: msg,
849 };
850 }
851 ValidationOutcome::Invalid {
852 message: localized(move || {
853 resolve_message_widget("time-edit-validation-not-a-time", &[])
854 }),
855 }
856 })
857}
858
859pub(crate) fn try_clamp_time_recovery(
864 pattern: &ParsedPattern,
865 raw: &str,
866 min: Option<Time>,
867 max: Option<Time>,
868) -> Option<(String, LocalizedString)> {
869 use crate::common::datetime::pattern::{PatternToken, SegmentKind};
870 let mut cursor = raw;
871 let mut hour24: Option<i8> = None;
872 let mut hour12: Option<i8> = None;
873 let mut minute: Option<i8> = None;
874 let mut second: Option<i8> = None;
875 let mut period: Option<i8> = None;
876 let mut clamp_notes: Vec<LocalizedString> = Vec::new();
877
878 for token in &pattern.tokens {
879 if cursor.is_empty() {
880 break;
881 }
882 match token {
883 PatternToken::Literal(lit) => {
884 if let Some(rest) = cursor.strip_prefix(lit.as_str()) {
885 cursor = rest;
886 } else if lit.starts_with(cursor) {
887 cursor = "";
888 } else {
889 return None;
890 }
891 }
892 PatternToken::Segment(kind) => {
893 if matches!(kind, SegmentKind::Period) {
894 let first = cursor.chars().next()?;
895 let upper = first.to_ascii_uppercase();
896 let consumed = cursor.chars().next().map(|c| c.len_utf8()).unwrap_or(0);
897 let after_first = &cursor[consumed..];
898 let consumed2 = after_first
899 .chars()
900 .next()
901 .filter(|c| c.is_ascii_alphabetic())
902 .map(|c| c.len_utf8())
903 .unwrap_or(0);
904 cursor = &after_first[consumed2..];
905 period = Some(if upper == 'P' { 1 } else { 0 });
906 continue;
907 }
908 let max_d = kind.max_digits();
909 if max_d == 0 {
910 continue;
911 }
912 let mut end = 0usize;
913 for (i, ch) in cursor.char_indices() {
914 if ch.is_ascii_digit() && end < max_d {
915 end = i + ch.len_utf8();
916 } else {
917 break;
918 }
919 }
920 if end == 0 {
921 return None;
922 }
923 let digits = &cursor[..end];
924 cursor = &cursor[end..];
925 let raw_v: i32 = digits.parse().ok()?;
926 let (lo, hi) = kind.value_range().unwrap_or((i32::MIN, i32::MAX));
927 let clamped = raw_v.clamp(lo, hi);
928 if clamped != raw_v {
929 let segment_key = match kind {
930 SegmentKind::Hour24
931 | SegmentKind::Hour24Short
932 | SegmentKind::Hour12
933 | SegmentKind::Hour12Short => "validation-segment-hour",
934 SegmentKind::Minute | SegmentKind::MinuteShort => {
935 "validation-segment-minute"
936 }
937 SegmentKind::Second | SegmentKind::SecondShort => {
938 "validation-segment-second"
939 }
940 _ => "validation-segment-value",
941 };
942 let label = resolve_message_widget(segment_key, &[]);
943 clamp_notes.push(localized(move || {
944 resolve_message_widget(
945 "validation-segment-clamped",
946 &[
947 ("segment", label.clone().into()),
948 ("raw", (raw_v as i64).into()),
949 ("clamped", (clamped as i64).into()),
950 ],
951 )
952 }));
953 }
954 match kind {
955 SegmentKind::Hour24 | SegmentKind::Hour24Short => hour24 = Some(clamped as i8),
956 SegmentKind::Hour12 | SegmentKind::Hour12Short => hour12 = Some(clamped as i8),
957 SegmentKind::Minute | SegmentKind::MinuteShort => minute = Some(clamped as i8),
958 SegmentKind::Second | SegmentKind::SecondShort => second = Some(clamped as i8),
959 _ => {}
960 }
961 }
962 }
963 }
964
965 let hour = match (hour24, hour12, period) {
966 (Some(h), _, _) => h,
967 (None, Some(h12), Some(p)) => (h12 % 12) + if p == 1 { 12 } else { 0 },
968 (None, Some(h12), None) => h12 % 12,
969 (None, None, _) => return None,
970 };
971 let t = Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0).ok()?;
972 let final_t = clamp_time(t, min, max);
973 if final_t != t {
974 clamp_notes.push(localized(move || {
975 resolve_message_widget("validation-clamped-to-range", &[])
976 }));
977 }
978 let formatted = format_value(pattern, None, Some(final_t));
979 let formatted_for_msg = formatted.clone();
980 let message = if clamp_notes.is_empty() {
981 localized(move || {
982 resolve_message_widget(
983 "validation-corrected-to",
984 &[("value", formatted_for_msg.clone().into())],
985 )
986 })
987 } else {
988 localized(move || {
992 let notes_str: String = clamp_notes
993 .iter()
994 .map(|n| n.resolve_now())
995 .collect::<Vec<_>>()
996 .join(", ");
997 resolve_message_widget(
998 "validation-corrected-with-notes",
999 &[("notes", notes_str.into())],
1000 )
1001 })
1002 };
1003 Some((formatted, message))
1004}