1#[cfg(test)]
42mod tests;
43
44use std::rc::Rc;
45
46use teksilo_canvas::{Rect, SizeProposal};
47use teksilo_core::accessibility::AccessNodeBuilder;
48use teksilo_core::accesskit::{Action, Role};
49use teksilo_core::build_context::BuildContext;
50use teksilo_core::event::{EventResponse, Key, WidgetEvent};
51use teksilo_core::signal::{Prop, Signal};
52use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
53use teksilo_core::widget_builder::HandlerSet;
54use teksilo_core::widget_id::WidgetId;
55use teksilo_i18n::{localized, resolve_message_widget};
56
57use crate::common::datetime::Time;
58use crate::common::datetime::pattern::{
59 ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
60 segment_at_position, step_time_field,
61};
62use crate::date_edit::ValidationBehavior;
63use crate::primitives::text_input_field::{ValidationFeedback, ValidationOutcome};
64use crate::text_input::TextInput;
65use teksilo_i18n::LocalizedString;
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum TimeFormat {
72 #[default]
74 Hour24,
75 Hour12,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum SecondsMode {
82 #[default]
84 Hidden,
85 Editable,
87}
88
89type OnValueChanged = Rc<dyn Fn(Option<Time>, &mut EventContext)>;
90
91pub struct TimeEdit {
96 value: Signal<Option<Time>>,
97 required_source: Option<Signal<Time>>,
100 format: Option<TimeFormat>,
104 seconds: SecondsMode,
105 pattern_override: Option<String>,
106 min_time: Option<Time>,
107 max_time: Option<Time>,
108 step_minutes: u32,
109 placeholder: LocalizedString,
110 enabled: Prop<bool>,
113 read_only: bool,
114 validation_behavior: ValidationBehavior,
115 width_policy: crate::date_edit::WidthPolicy,
116 label: Option<LocalizedString>,
117 on_value_changed: Option<OnValueChanged>,
118 text_signal: Signal<String>,
119 focused: Signal<bool>,
120 feedback: Signal<ValidationFeedback>,
121 style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
122 root_child_id: Option<WidgetId>,
123 tooltip_text: Option<LocalizedString>,
127 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
129 composite_tooltip_content: Option<Box<dyn Widget>>,
131}
132
133impl std::fmt::Debug for TimeEdit {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("TimeEdit")
136 .field("format", &self.format)
137 .field("seconds", &self.seconds)
138 .finish_non_exhaustive()
139 }
140}
141
142impl TimeEdit {
143 pub fn new(value: Signal<Option<Time>>) -> Self {
145 Self {
146 value,
147 required_source: None,
148 format: None,
149 seconds: SecondsMode::Hidden,
150 pattern_override: None,
151 min_time: None,
152 max_time: None,
153 step_minutes: 1,
154 placeholder: LocalizedString::literal(String::new()),
155 enabled: Prop::Static(true),
156 read_only: false,
157 validation_behavior: ValidationBehavior::AutoCorrect,
158 width_policy: crate::date_edit::WidthPolicy::Default,
159 label: None,
160 on_value_changed: None,
161 text_signal: Signal::new(String::new()),
162 focused: Signal::new(false),
163 feedback: Signal::new(ValidationFeedback::Pristine),
164 style_override: None,
165 root_child_id: None,
166 tooltip_text: None,
167 rich_tooltip_source: None,
168 composite_tooltip_content: None,
169 }
170 }
171
172 pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
174 self.style_override = Some(std::rc::Rc::new(style));
175 self
176 }
177
178 pub fn required(value: Signal<Time>) -> Self {
181 let proxy: Signal<Option<Time>> = Signal::new(Some(value.get()));
182 let mut s = Self::new(proxy);
183 s.required_source = Some(value);
184 s
185 }
186
187 pub fn format(mut self, f: TimeFormat) -> Self {
192 self.format = Some(f);
193 self
194 }
195
196 pub fn seconds(mut self, mode: SecondsMode) -> Self {
198 self.seconds = mode;
199 self
200 }
201
202 pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
205 self.pattern_override = Some(p.into());
206 self
207 }
208
209 pub fn min_time(mut self, t: Time) -> Self {
211 self.min_time = Some(t);
212 self
213 }
214
215 pub fn max_time(mut self, t: Time) -> Self {
217 self.max_time = Some(t);
218 self
219 }
220
221 pub fn step_minutes(mut self, n: u32) -> Self {
223 self.step_minutes = n.max(1);
224 self
225 }
226
227 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
229 let ls: LocalizedString = text.into();
230 self.placeholder = ls;
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 validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
250 self.validation_behavior = behavior;
251 self
252 }
253
254 pub fn width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
258 self.width_policy = policy;
259 self
260 }
261
262 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
264 self.feedback.clone()
265 }
266
267 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
269 let ls: LocalizedString = label.into();
270 self.label = Some(ls);
271 self
272 }
273
274 pub fn on_value_changed(
277 mut self,
278 f: impl Fn(Option<Time>, &mut EventContext) + 'static,
279 ) -> Self {
280 self.on_value_changed = Some(Rc::new(f));
281 self
282 }
283
284 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
287 self.tooltip_text = Some(text.into());
288 self.rich_tooltip_source = None;
289 self.composite_tooltip_content = None;
290 self
291 }
292
293 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
296 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
297 self.tooltip_text = None;
298 self.composite_tooltip_content = None;
299 self
300 }
301
302 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
305 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
306 self.tooltip_text = None;
307 self.composite_tooltip_content = None;
308 self
309 }
310
311 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
314 self.composite_tooltip_content = Some(Box::new(content));
315 self.tooltip_text = None;
316 self.rich_tooltip_source = None;
317 self
318 }
319
320 pub fn value(&self) -> Signal<Option<Time>> {
322 self.value.clone()
323 }
324
325 fn resolved_pattern(&self, format: TimeFormat) -> String {
326 if let Some(p) = self.pattern_override.clone() {
327 return p;
328 }
329 time_pattern_for(format, self.seconds)
330 }
331}
332
333pub(crate) fn time_pattern_for(format: TimeFormat, seconds: SecondsMode) -> String {
337 match (format, seconds) {
338 (TimeFormat::Hour24, SecondsMode::Hidden) => "%H:%M".into(),
339 (TimeFormat::Hour24, SecondsMode::Editable) => "%H:%M:%S".into(),
340 (TimeFormat::Hour12, SecondsMode::Hidden) => "%I:%M %p".into(),
341 (TimeFormat::Hour12, SecondsMode::Editable) => "%I:%M:%S %p".into(),
342 }
343}
344
345impl Widget for TimeEdit {
346 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
347 if let Some(src) = self.required_source.clone() {
351 {
352 let proxy = self.value.clone();
353 ctx.effect(&src, move |new| {
354 if proxy.get() != Some(*new) {
355 proxy.set(Some(*new));
356 }
357 });
358 }
359 {
360 let src_clone = src;
361 ctx.effect(&self.value, move |v| {
362 if let Some(t) = v
363 && src_clone.get() != *t
364 {
365 src_clone.set(*t);
366 }
367 });
368 }
369 }
370
371 let self_id = ctx.self_id();
372 ctx.enabled_when(self_id, self.enabled.clone());
374 let enabled = self.enabled.clone();
375 let read_only = self.read_only;
376
377 ctx.locale_signal().bind_to(
387 ctx.self_id(),
388 ctx.binding_registry(),
389 teksilo_core::binding::BindingLevel::Rebuild,
390 );
391
392 let format = self.format.unwrap_or_else(|| {
393 let tag = ctx.locale_signal().get().unwrap_or_default();
394 if crate::common::datetime::prefers_12_hour_clock(&tag) {
395 TimeFormat::Hour12
396 } else {
397 TimeFormat::Hour24
398 }
399 });
400 let pattern_string = self.resolved_pattern(format);
401 let parsed_pattern = ParsedPattern::parse(&pattern_string)
402 .unwrap_or_else(|_| ParsedPattern::parse("%H:%M").unwrap());
403 let pattern_rc = Rc::new(parsed_pattern);
404 let on_value_changed = self.on_value_changed.clone();
405 let min = self.min_time;
406 let max = self.max_time;
407 let step_minutes = self.step_minutes as i64;
408
409 {
411 let init = match self.value.get() {
412 Some(t) => format_value(&pattern_rc, None, Some(t)),
413 None => String::new(),
414 };
415 self.text_signal.set(init);
416 }
417
418 {
420 let text_signal = self.text_signal.clone();
421 let focused = self.focused.clone();
422 let pattern = pattern_rc.clone();
423 ctx.effect(&self.value, move |new_value| {
424 if focused.get() {
425 return;
426 }
427 let formatted = match new_value {
428 Some(t) => format_value(&pattern, None, Some(*t)),
429 None => String::new(),
430 };
431 if text_signal.get() != formatted {
432 text_signal.set(formatted);
433 }
434 });
435 }
436
437 let validation_behavior = self.validation_behavior;
442 let validator: crate::primitives::text_input_field::ValidatorFn = {
443 let pattern = pattern_rc.clone();
444 Rc::new(move |raw: &str| -> ValidationOutcome {
445 let trimmed = raw.trim();
446 if trimmed.is_empty() {
447 return ValidationOutcome::Valid;
448 }
449 if let Some(ParsedValue::Time(t)) =
450 parse_value(&pattern, trimmed, ParseTarget::TimeOnly)
451 {
452 let clamped = clamp_time(t, min, max);
453 let formatted = format_value(&pattern, None, Some(clamped));
454 if formatted == trimmed && clamped == t {
455 return ValidationOutcome::Valid;
456 }
457 return ValidationOutcome::Corrected {
458 corrected: formatted.clone(),
459 message: localized(move || {
460 resolve_message_widget(
461 "validation-corrected-to",
462 &[("value", formatted.clone().into())],
463 )
464 }),
465 };
466 }
467 if validation_behavior == ValidationBehavior::AutoCorrect
468 && let Some((corrected, msg)) =
469 try_clamp_time_recovery(&pattern, trimmed, min, max)
470 {
471 return ValidationOutcome::Corrected {
472 corrected,
473 message: msg,
474 };
475 }
476 ValidationOutcome::Invalid {
477 message: localized(move || {
478 resolve_message_widget("time-edit-validation-not-a-time", &[])
479 }),
480 }
481 })
482 };
483
484 let commit: Rc<dyn Fn(&mut EventContext)> = {
487 let value_signal = self.value.clone();
488 let text_signal = self.text_signal.clone();
489 let feedback_signal = self.feedback.clone();
490 let pattern = pattern_rc.clone();
491 let on_value_changed = on_value_changed.clone();
492 Rc::new(move |ctx_evt: &mut EventContext| {
493 if matches!(feedback_signal.get(), ValidationFeedback::Invalid { .. }) {
494 return;
495 }
496 let raw = text_signal.get();
497 let trimmed = raw.trim();
498 let new_value: Option<Time> = if trimmed.is_empty() {
499 None
500 } else {
501 match parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
502 Some(ParsedValue::Time(t)) => Some(clamp_time(t, min, max)),
503 _ => value_signal.get(),
504 }
505 };
506 if value_signal.get() != new_value {
507 value_signal.set(new_value);
508 if let Some(cb) = on_value_changed.as_ref() {
509 cb(new_value, ctx_evt);
510 }
511 }
512 })
513 };
514
515 let _ = step_minutes;
523
524 let pattern_for_filter = pattern_rc.clone();
532 let mask_string = mask_for_pattern(&pattern_rc);
533 let mut text_input = TextInput::new(self.text_signal.clone())
534 .placeholder(self.placeholder.clone())
535 .enabled(enabled)
536 .read_only(read_only)
537 .input_mask(mask_string)
538 .validator({
539 let v = validator.clone();
540 move |s| (v)(s)
541 })
542 .char_filter(move |c: char| {
543 if c.is_ascii_digit() || c == ' ' || c == ':' {
544 return true;
545 }
546 if matches!(c, 'a' | 'A' | 'p' | 'P' | 'm' | 'M') {
547 return true;
548 }
549 for tok in &pattern_for_filter.tokens {
550 if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
551 && s.chars().any(|x| x == c)
552 {
553 return true;
554 }
555 }
556 false
557 })
558 .on_submit_fn({
559 let commit = commit.clone();
560 move |ctx_evt| commit(ctx_evt)
561 })
562 .on_blur_fn({
563 let commit = commit.clone();
564 move |ctx_evt| commit(ctx_evt)
565 });
566 if let Some(label) = self.label.clone() {
567 text_input = text_input.label(label);
568 }
569
570 let caret_for_step = text_input.caret_position();
571 let caret_setter_for_step = text_input.caret_setter();
572
573 {
574 let inner_feedback = text_input.validation_feedback_signal();
575 let outer_feedback = self.feedback.clone();
576 ctx.effect(&inner_feedback, move |fb| {
577 if outer_feedback.get() != *fb {
578 outer_feedback.set(fb.clone());
579 }
580 });
581 }
582
583 let body_id = match self.width_policy {
588 crate::date_edit::WidthPolicy::Default => ctx.add(text_input),
589 crate::date_edit::WidthPolicy::Fill => {
590 let inner_id = ctx.add(text_input);
591 ctx.add(
592 crate::primitives::Expand::horizontal()
593 .respect_intrinsic()
594 .child_id(inner_id),
595 )
596 }
597 };
598 let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
599 &self.style_override,
600 ctx,
601 );
602 let cfg = teksilo_core::styles::DateEditStyleConfig { body: body_id };
603 let root_id = style.make_body(&cfg, ctx);
604 self.root_child_id = Some(root_id);
605
606 if let Some(content) = self.composite_tooltip_content.take() {
608 let delay = ctx.theme().motion.tooltip_delay_heavy;
609 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
610 } else if let Some(source) = self.rich_tooltip_source.clone() {
611 let delay = ctx.theme().motion.tooltip_delay;
612 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
613 } else if let Some(text) = self.tooltip_text.clone() {
614 let delay = ctx.theme().motion.tooltip_delay;
615 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
616 }
617
618 let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = {
620 let pattern_for_step = pattern_rc.clone();
621 let value_for_step = self.value.clone();
622 let text_for_step = self.text_signal.clone();
623 let on_changed_for_step = self.on_value_changed.clone();
624 let min_for_step = self.min_time;
625 let max_for_step = self.max_time;
626 let caret_for_step = caret_for_step.clone();
627 let caret_setter = caret_setter_for_step.clone();
628 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
629 let caret = caret_for_step.get();
630 let Some((_, _, kind)) = segment_at_position(&pattern_for_step, caret) else {
631 return;
632 };
633 let current = value_for_step.get().unwrap_or_else(Time::midnight);
634 let stepped = step_time_field(current, kind, delta);
635 let clamped = clamp_time(stepped, min_for_step, max_for_step);
636 value_for_step.set(Some(clamped));
637 text_for_step.set(format_value(&pattern_for_step, None, Some(clamped)));
638 caret_setter(caret);
641 if let Some(cb) = on_changed_for_step.as_ref() {
642 cb(Some(clamped), ctx_evt);
643 }
644 ctx_evt.request_frame();
645 })
646 };
647
648 let step_for_key = segment_step.clone();
654 let handlers = HandlerSet::new()
655 .focus_within(self.focused.clone())
656 .on_key_preview(move |event, ctx_evt| {
657 if read_only {
663 return EventResponse::Ignored;
664 }
665 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
666 return EventResponse::Ignored;
667 };
668 let mult = if modifiers.shift() { 10 } else { 1 };
669 let delta = match key {
670 Key::ArrowUp => mult,
671 Key::ArrowDown => -mult,
672 Key::PageUp => 10 * mult,
673 Key::PageDown => -10 * mult,
674 _ => return EventResponse::Ignored,
675 };
676 step_for_key(delta, ctx_evt);
677 EventResponse::Handled
678 });
679 ctx.apply_self_handlers(handlers);
680
681 let self_id = ctx.self_id();
684 self.value.bind_to(
685 self_id,
686 ctx.binding_registry(),
687 teksilo_core::binding::BindingLevel::AccessibilityOnly,
688 );
689
690 vec![root_id]
691 }
692
693 fn layout_response(
694 &self,
695 proposal: SizeProposal,
696 ctx: &LayoutContext,
697 ) -> teksilo_core::widget::LayoutResponse {
698 match self.root_child_id {
702 Some(id) => ctx
703 .child_layout_response(id, proposal)
704 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
705 None => proposal.resolve(0.0, 0.0).into(),
706 }
707 }
708
709 fn place_children(
710 &self,
711 bounds: Rect,
712 _proposal: SizeProposal,
713 children: &mut [WidgetPlacement],
714 _ctx: &LayoutContext,
715 ) {
716 for child in children.iter_mut() {
717 child.origin = bounds.origin();
718 child.size = bounds.size();
719 }
720 }
721
722 fn children(&self) -> Vec<WidgetId> {
723 self.root_child_id.into_iter().collect()
724 }
725
726 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
727 builder.set_role(Role::TimeInput);
728 if let Some(ref label) = self.label {
729 builder.set_name(label.resolve_now());
730 } else {
731 builder.set_name(resolve_message_widget("time-edit-name", &[]));
732 }
733 match self.value.get() {
734 Some(t) => {
735 builder.set_value(format!(
736 "{:02}:{:02}:{:02}",
737 t.hour(),
738 t.minute(),
739 t.second()
740 ));
741 }
742 None => {
743 if !self.placeholder.resolve_now().is_empty() {
744 builder.set_placeholder(self.placeholder.resolve_now());
745 } else {
746 builder.set_placeholder(resolve_message_widget("time-edit-placeholder", &[]));
747 }
748 }
749 }
750 if self.read_only {
752 builder.set_read_only();
753 }
754 builder.add_action(Action::Focus);
755 }
759}
760
761pub(crate) fn clamp_time(t: Time, min: Option<Time>, max: Option<Time>) -> Time {
762 let t = match min {
763 Some(min) if t < min => min,
764 _ => t,
765 };
766 match max {
767 Some(max) if t > max => max,
768 _ => t,
769 }
770}
771
772pub(crate) fn build_time_validator(
779 pattern: Rc<ParsedPattern>,
780 min: Option<Time>,
781 max: Option<Time>,
782 behavior: ValidationBehavior,
783) -> crate::primitives::text_input_field::ValidatorFn {
784 Rc::new(move |raw: &str| -> ValidationOutcome {
785 let trimmed = raw.trim();
786 if trimmed.is_empty() {
787 return ValidationOutcome::Valid;
788 }
789 if let Some(ParsedValue::Time(t)) = parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
790 let clamped = clamp_time(t, min, max);
791 let formatted = format_value(&pattern, None, Some(clamped));
792 if formatted == trimmed && clamped == t {
793 return ValidationOutcome::Valid;
794 }
795 return ValidationOutcome::Corrected {
796 corrected: formatted.clone(),
797 message: localized(move || {
798 resolve_message_widget(
799 "validation-corrected-to",
800 &[("value", formatted.clone().into())],
801 )
802 }),
803 };
804 }
805 if behavior == ValidationBehavior::AutoCorrect
806 && let Some((corrected, msg)) = try_clamp_time_recovery(&pattern, trimmed, min, max)
807 {
808 return ValidationOutcome::Corrected {
809 corrected,
810 message: msg,
811 };
812 }
813 ValidationOutcome::Invalid {
814 message: localized(move || {
815 resolve_message_widget("time-edit-validation-not-a-time", &[])
816 }),
817 }
818 })
819}
820
821pub(crate) fn try_clamp_time_recovery(
826 pattern: &ParsedPattern,
827 raw: &str,
828 min: Option<Time>,
829 max: Option<Time>,
830) -> Option<(String, LocalizedString)> {
831 use crate::common::datetime::pattern::{PatternToken, SegmentKind};
832 let mut cursor = raw;
833 let mut hour24: Option<i8> = None;
834 let mut hour12: Option<i8> = None;
835 let mut minute: Option<i8> = None;
836 let mut second: Option<i8> = None;
837 let mut period: Option<i8> = None;
838 let mut clamp_notes: Vec<LocalizedString> = Vec::new();
839
840 for token in &pattern.tokens {
841 if cursor.is_empty() {
842 break;
843 }
844 match token {
845 PatternToken::Literal(lit) => {
846 if let Some(rest) = cursor.strip_prefix(lit.as_str()) {
847 cursor = rest;
848 } else if lit.starts_with(cursor) {
849 cursor = "";
850 } else {
851 return None;
852 }
853 }
854 PatternToken::Segment(kind) => {
855 if matches!(kind, SegmentKind::Period) {
856 let first = cursor.chars().next()?;
857 let upper = first.to_ascii_uppercase();
858 let consumed = cursor.chars().next().map(|c| c.len_utf8()).unwrap_or(0);
859 let after_first = &cursor[consumed..];
860 let consumed2 = after_first
861 .chars()
862 .next()
863 .filter(|c| c.is_ascii_alphabetic())
864 .map(|c| c.len_utf8())
865 .unwrap_or(0);
866 cursor = &after_first[consumed2..];
867 period = Some(if upper == 'P' { 1 } else { 0 });
868 continue;
869 }
870 let max_d = kind.max_digits();
871 if max_d == 0 {
872 continue;
873 }
874 let mut end = 0usize;
875 for (i, ch) in cursor.char_indices() {
876 if ch.is_ascii_digit() && end < max_d {
877 end = i + ch.len_utf8();
878 } else {
879 break;
880 }
881 }
882 if end == 0 {
883 return None;
884 }
885 let digits = &cursor[..end];
886 cursor = &cursor[end..];
887 let raw_v: i32 = digits.parse().ok()?;
888 let (lo, hi) = kind.value_range().unwrap_or((i32::MIN, i32::MAX));
889 let clamped = raw_v.clamp(lo, hi);
890 if clamped != raw_v {
891 let segment_key = match kind {
892 SegmentKind::Hour24
893 | SegmentKind::Hour24Short
894 | SegmentKind::Hour12
895 | SegmentKind::Hour12Short => "validation-segment-hour",
896 SegmentKind::Minute | SegmentKind::MinuteShort => {
897 "validation-segment-minute"
898 }
899 SegmentKind::Second | SegmentKind::SecondShort => {
900 "validation-segment-second"
901 }
902 _ => "validation-segment-value",
903 };
904 let label = resolve_message_widget(segment_key, &[]);
905 clamp_notes.push(localized(move || {
906 resolve_message_widget(
907 "validation-segment-clamped",
908 &[
909 ("segment", label.clone().into()),
910 ("raw", (raw_v as i64).into()),
911 ("clamped", (clamped as i64).into()),
912 ],
913 )
914 }));
915 }
916 match kind {
917 SegmentKind::Hour24 | SegmentKind::Hour24Short => hour24 = Some(clamped as i8),
918 SegmentKind::Hour12 | SegmentKind::Hour12Short => hour12 = Some(clamped as i8),
919 SegmentKind::Minute | SegmentKind::MinuteShort => minute = Some(clamped as i8),
920 SegmentKind::Second | SegmentKind::SecondShort => second = Some(clamped as i8),
921 _ => {}
922 }
923 }
924 }
925 }
926
927 let hour = match (hour24, hour12, period) {
928 (Some(h), _, _) => h,
929 (None, Some(h12), Some(p)) => (h12 % 12) + if p == 1 { 12 } else { 0 },
930 (None, Some(h12), None) => h12 % 12,
931 (None, None, _) => return None,
932 };
933 let t = Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0).ok()?;
934 let final_t = clamp_time(t, min, max);
935 if final_t != t {
936 clamp_notes.push(localized(move || {
937 resolve_message_widget("validation-clamped-to-range", &[])
938 }));
939 }
940 let formatted = format_value(pattern, None, Some(final_t));
941 let formatted_for_msg = formatted.clone();
942 let message = if clamp_notes.is_empty() {
943 localized(move || {
944 resolve_message_widget(
945 "validation-corrected-to",
946 &[("value", formatted_for_msg.clone().into())],
947 )
948 })
949 } else {
950 localized(move || {
954 let notes_str: String = clamp_notes
955 .iter()
956 .map(|n| n.resolve_now())
957 .collect::<Vec<_>>()
958 .join(", ");
959 resolve_message_widget(
960 "validation-corrected-with-notes",
961 &[("notes", notes_str.into())],
962 )
963 })
964 };
965 Some((formatted, message))
966}