1#[cfg(test)]
57mod tests;
58
59use std::rc::Rc;
60
61use teksilo_canvas::{Rect, SizeProposal};
62use teksilo_core::accessibility::AccessNodeBuilder;
63use teksilo_core::accesskit::{Action, Role};
64use teksilo_core::build_context::BuildContext;
65use teksilo_core::event::{EventResponse, Key, WidgetEvent};
66use teksilo_core::signal::{Prop, Signal};
67use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
68use teksilo_core::widget_builder::HandlerSet;
69use teksilo_core::widget_id::WidgetId;
70use teksilo_i18n::{localized, resolve_message_widget};
71
72use crate::common::datetime::Time;
73use crate::common::datetime::pattern::{
74 ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
75 segment_at_position, step_time_field,
76};
77use crate::date_edit::ValidationBehavior;
78use crate::primitives::text_input_field::{ValidationFeedback, ValidationOutcome};
79use crate::text_input::TextInput;
80use teksilo_i18n::LocalizedString;
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum TimeFormat {
87 #[default]
89 Hour24,
90 Hour12,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum SecondsMode {
97 #[default]
99 Hidden,
100 Editable,
102}
103
104type OnValueChanged = Rc<dyn Fn(Option<Time>, &mut EventContext)>;
105
106pub struct TimeEdit {
111 value: Signal<Option<Time>>,
112 required_source: Option<Signal<Time>>,
115 format: Option<TimeFormat>,
119 seconds: SecondsMode,
120 pattern_override: Option<String>,
121 min_time: Option<Time>,
122 max_time: Option<Time>,
123 step_minutes: u32,
124 placeholder: LocalizedString,
125 enabled: Prop<bool>,
128 read_only: bool,
129 validation_behavior: ValidationBehavior,
130 width_policy: crate::date_edit::WidthPolicy,
131 label: Option<LocalizedString>,
132 on_value_changed: Option<OnValueChanged>,
133 text_signal: Signal<String>,
134 focused: Signal<bool>,
135 feedback: Signal<ValidationFeedback>,
136 style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
137 root_child_id: Option<WidgetId>,
138 field_id: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
141 tooltip_text: Option<LocalizedString>,
145 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
147 composite_tooltip_content: Option<Box<dyn Widget>>,
149}
150
151impl std::fmt::Debug for TimeEdit {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.debug_struct("TimeEdit")
154 .field("format", &self.format)
155 .field("seconds", &self.seconds)
156 .finish_non_exhaustive()
157 }
158}
159
160impl TimeEdit {
161 pub fn new(value: Signal<Option<Time>>) -> Self {
163 Self {
164 value,
165 required_source: None,
166 format: None,
167 seconds: SecondsMode::Hidden,
168 pattern_override: None,
169 min_time: None,
170 max_time: None,
171 step_minutes: 1,
172 placeholder: LocalizedString::literal(String::new()),
173 enabled: Prop::Static(true),
174 read_only: false,
175 validation_behavior: ValidationBehavior::AutoCorrect,
176 width_policy: crate::date_edit::WidthPolicy::Default,
177 label: None,
178 on_value_changed: None,
179 text_signal: Signal::new(String::new()),
180 focused: Signal::new(false),
181 feedback: Signal::new(ValidationFeedback::Pristine),
182 style_override: None,
183 root_child_id: None,
184 field_id: std::rc::Rc::new(std::cell::Cell::new(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 required(value: Signal<Time>) -> Self {
200 let proxy: Signal<Option<Time>> = Signal::new(Some(value.get()));
201 let mut s = Self::new(proxy);
202 s.required_source = Some(value);
203 s
204 }
205
206 pub fn format(mut self, f: TimeFormat) -> Self {
211 self.format = Some(f);
212 self
213 }
214
215 pub fn seconds(mut self, mode: SecondsMode) -> Self {
217 self.seconds = mode;
218 self
219 }
220
221 pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
224 self.pattern_override = Some(p.into());
225 self
226 }
227
228 pub fn min_time(mut self, t: Time) -> Self {
230 self.min_time = Some(t);
231 self
232 }
233
234 pub fn max_time(mut self, t: Time) -> Self {
236 self.max_time = Some(t);
237 self
238 }
239
240 pub fn step_minutes(mut self, n: u32) -> Self {
249 self.step_minutes = n.max(1);
250 self
251 }
252
253 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
255 let ls: LocalizedString = text.into();
256 self.placeholder = ls;
257 self
258 }
259
260 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
263 self.enabled = enabled.into();
264 self
265 }
266
267 pub fn read_only(mut self, read_only: bool) -> Self {
269 self.read_only = read_only;
270 self
271 }
272
273 pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
276 self.validation_behavior = behavior;
277 self
278 }
279
280 pub fn width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
284 self.width_policy = policy;
285 self
286 }
287
288 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
290 self.feedback.clone()
291 }
292
293 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
295 let ls: LocalizedString = label.into();
296 self.label = Some(ls);
297 self
298 }
299
300 pub fn on_value_changed(
303 mut self,
304 f: impl Fn(Option<Time>, &mut EventContext) + 'static,
305 ) -> Self {
306 self.on_value_changed = Some(Rc::new(f));
307 self
308 }
309
310 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
313 self.tooltip_text = Some(text.into());
314 self.rich_tooltip_source = None;
315 self.composite_tooltip_content = None;
316 self
317 }
318
319 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
322 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
323 self.tooltip_text = None;
324 self.composite_tooltip_content = None;
325 self
326 }
327
328 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
331 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
332 self.tooltip_text = None;
333 self.composite_tooltip_content = None;
334 self
335 }
336
337 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
340 self.composite_tooltip_content = Some(Box::new(content));
341 self.tooltip_text = None;
342 self.rich_tooltip_source = None;
343 self
344 }
345
346 pub fn value(&self) -> Signal<Option<Time>> {
348 self.value.clone()
349 }
350
351 fn resolved_pattern(&self, format: TimeFormat) -> String {
352 if let Some(p) = self.pattern_override.clone() {
353 return p;
354 }
355 time_pattern_for(format, self.seconds)
356 }
357}
358
359pub(crate) fn time_pattern_for(format: TimeFormat, seconds: SecondsMode) -> String {
363 match (format, seconds) {
364 (TimeFormat::Hour24, SecondsMode::Hidden) => "%H:%M".into(),
365 (TimeFormat::Hour24, SecondsMode::Editable) => "%H:%M:%S".into(),
366 (TimeFormat::Hour12, SecondsMode::Hidden) => "%I:%M %p".into(),
367 (TimeFormat::Hour12, SecondsMode::Editable) => "%I:%M:%S %p".into(),
368 }
369}
370
371impl Widget for TimeEdit {
372 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
373 if let Some(src) = self.required_source.clone() {
377 {
378 let proxy = self.value.clone();
379 ctx.effect(&src, move |new| {
380 if proxy.get() != Some(*new) {
381 proxy.set(Some(*new));
382 }
383 });
384 }
385 {
386 let src_clone = src;
387 ctx.effect(&self.value, move |v| {
388 if let Some(t) = v
389 && src_clone.get() != *t
390 {
391 src_clone.set(*t);
392 }
393 });
394 }
395 }
396
397 let self_id = ctx.self_id();
398 ctx.enabled_when(self_id, self.enabled.clone());
400 let enabled = self.enabled.clone();
401 let read_only = self.read_only;
402
403 ctx.locale_signal().bind_to(
413 ctx.self_id(),
414 ctx.binding_registry(),
415 teksilo_core::binding::BindingLevel::Rebuild,
416 );
417
418 let format = self.format.unwrap_or_else(|| {
419 let tag = ctx.locale_signal().get().unwrap_or_default();
420 if crate::common::datetime::prefers_12_hour_clock(&tag) {
421 TimeFormat::Hour12
422 } else {
423 TimeFormat::Hour24
424 }
425 });
426 let pattern_string = self.resolved_pattern(format);
427 let parsed_pattern = ParsedPattern::parse(&pattern_string)
428 .unwrap_or_else(|_| ParsedPattern::parse("%H:%M").unwrap());
429 let pattern_rc = Rc::new(parsed_pattern);
430 let on_value_changed = self.on_value_changed.clone();
431 let min = self.min_time;
432 let max = self.max_time;
433 let step_minutes = self.step_minutes as i64;
434
435 {
437 let init = match self.value.get() {
438 Some(t) => format_value(&pattern_rc, None, Some(t)),
439 None => String::new(),
440 };
441 self.text_signal.set(init);
442 }
443
444 {
446 let text_signal = self.text_signal.clone();
447 let focused = self.focused.clone();
448 let pattern = pattern_rc.clone();
449 ctx.effect(&self.value, move |new_value| {
450 if focused.get() {
451 return;
452 }
453 let formatted = match new_value {
454 Some(t) => format_value(&pattern, None, Some(*t)),
455 None => String::new(),
456 };
457 if text_signal.get() != formatted {
458 text_signal.set(formatted);
459 }
460 });
461 }
462
463 let validation_behavior = self.validation_behavior;
468 let validator: crate::primitives::text_input_field::ValidatorFn = {
469 let pattern = pattern_rc.clone();
470 Rc::new(move |raw: &str| -> ValidationOutcome {
471 let trimmed = raw.trim();
472 if trimmed.is_empty() {
473 return ValidationOutcome::Valid;
474 }
475 if let Some(ParsedValue::Time(t)) =
476 parse_value(&pattern, trimmed, ParseTarget::TimeOnly)
477 {
478 let clamped = clamp_time(t, min, max);
479 let formatted = format_value(&pattern, None, Some(clamped));
480 if formatted == trimmed && clamped == t {
481 return ValidationOutcome::Valid;
482 }
483 return ValidationOutcome::Corrected {
484 corrected: formatted.clone(),
485 message: localized(move || {
486 resolve_message_widget(
487 "validation-corrected-to",
488 &[("value", formatted.clone().into())],
489 )
490 }),
491 };
492 }
493 if validation_behavior == ValidationBehavior::AutoCorrect
494 && let Some((corrected, msg)) =
495 try_clamp_time_recovery(&pattern, trimmed, min, max)
496 {
497 return ValidationOutcome::Corrected {
498 corrected,
499 message: msg,
500 };
501 }
502 ValidationOutcome::Invalid {
503 message: localized(move || {
504 resolve_message_widget("time-edit-validation-not-a-time", &[])
505 }),
506 }
507 })
508 };
509
510 let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
514 let value_signal = self.value.clone();
515 let text_signal = self.text_signal.clone();
516 let feedback_signal = self.feedback.clone();
517 let pattern = pattern_rc.clone();
518 let on_value_changed = on_value_changed.clone();
519 Rc::new(move |ctx_evt: &mut EventContext| {
520 if matches!(feedback_signal.get(), ValidationFeedback::Invalid { .. }) {
521 return false;
522 }
523 let raw = text_signal.get();
524 let trimmed = raw.trim();
525 let (new_value, accepted): (Option<Time>, bool) = if trimmed.is_empty() {
526 (None, true)
527 } else {
528 match parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
529 Some(ParsedValue::Time(t)) => (Some(clamp_time(t, min, max)), true),
530 _ => (value_signal.get(), false),
531 }
532 };
533 if value_signal.get() != new_value {
534 value_signal.set(new_value);
535 if let Some(cb) = on_value_changed.as_ref() {
536 cb(new_value, ctx_evt);
537 }
538 }
539 accepted
540 })
541 };
542
543 let _ = step_minutes;
551
552 let pattern_for_filter = pattern_rc.clone();
560 let mask_string = mask_for_pattern(&pattern_rc);
561 let mut text_input = TextInput::new(self.text_signal.clone())
562 .placeholder(self.placeholder.clone())
563 .on_access_set_value({
568 let text_signal = self.text_signal.clone();
569 let commit = commit.clone();
570 move |text: &str, ctx: &mut EventContext| {
575 text_signal.set(text.to_string());
576 commit(ctx)
577 }
578 })
579 .enabled(enabled)
580 .read_only(read_only)
581 .input_mask(mask_string)
582 .validator({
583 let v = validator.clone();
584 move |s| (v)(s)
585 })
586 .char_filter(move |c: char| {
587 if c.is_ascii_digit() || c == ' ' || c == ':' {
588 return true;
589 }
590 if matches!(c, 'a' | 'A' | 'p' | 'P' | 'm' | 'M') {
591 return true;
592 }
593 for tok in &pattern_for_filter.tokens {
594 if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
595 && s.chars().any(|x| x == c)
596 {
597 return true;
598 }
599 }
600 false
601 })
602 .on_submit_fn({
603 let commit = commit.clone();
604 move |ctx_evt| {
605 commit(ctx_evt);
606 }
607 })
608 .on_blur_fn({
609 let commit = commit.clone();
610 move |ctx_evt| {
611 commit(ctx_evt);
612 }
613 });
614 text_input = text_input.label(
618 self.label
619 .clone()
620 .unwrap_or_else(|| localized(|| resolve_message_widget("time-edit-name", &[]))),
621 );
622 self.field_id = text_input.field_id();
623
624 let caret_for_step = text_input.caret_position();
625 let caret_setter_for_step = text_input.caret_setter();
626
627 {
628 let inner_feedback = text_input.validation_feedback_signal();
629 let outer_feedback = self.feedback.clone();
630 ctx.effect(&inner_feedback, move |fb| {
631 if outer_feedback.get() != *fb {
632 outer_feedback.set(fb.clone());
633 }
634 });
635 }
636
637 let body_id = match self.width_policy {
642 crate::date_edit::WidthPolicy::Default => ctx.add(text_input),
643 crate::date_edit::WidthPolicy::Fill => {
644 let inner_id = ctx.add(text_input);
645 ctx.add(
646 crate::primitives::Expand::horizontal()
647 .respect_intrinsic()
648 .child(inner_id),
649 )
650 }
651 };
652 let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
653 &self.style_override,
654 ctx,
655 );
656 let cfg = teksilo_core::styles::DateEditStyleConfig { body: body_id };
657 let root_id = style.make_body(&cfg, ctx);
658 self.root_child_id = Some(root_id);
659
660 if let Some(content) = self.composite_tooltip_content.take() {
662 let delay = ctx.theme().motion.tooltip_delay_heavy;
663 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
664 } else if let Some(source) = self.rich_tooltip_source.clone() {
665 let delay = ctx.theme().motion.tooltip_delay;
666 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
667 } else if let Some(text) = self.tooltip_text.clone() {
668 let delay = ctx.theme().motion.tooltip_delay;
669 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
670 }
671
672 let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = {
674 let pattern_for_step = pattern_rc.clone();
675 let value_for_step = self.value.clone();
676 let text_for_step = self.text_signal.clone();
677 let on_changed_for_step = self.on_value_changed.clone();
678 let min_for_step = self.min_time;
679 let max_for_step = self.max_time;
680 let caret_for_step = caret_for_step.clone();
681 let caret_setter = caret_setter_for_step.clone();
682 Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
683 let caret = caret_for_step.get();
684 let Some((_, _, kind)) = segment_at_position(&pattern_for_step, caret) else {
685 return;
686 };
687 let current = value_for_step.get().unwrap_or_else(Time::midnight);
688 let stepped = step_time_field(current, kind, delta);
689 let clamped = clamp_time(stepped, min_for_step, max_for_step);
690 value_for_step.set(Some(clamped));
691 text_for_step.set(format_value(&pattern_for_step, None, Some(clamped)));
692 caret_setter(caret);
695 if let Some(cb) = on_changed_for_step.as_ref() {
696 cb(Some(clamped), ctx_evt);
697 }
698 ctx_evt.request_frame();
699 })
700 };
701
702 let step_for_key = segment_step.clone();
708 let handlers = HandlerSet::new()
709 .focus_within(self.focused.clone())
710 .on_key_preview(move |event, ctx_evt| {
711 if read_only {
717 return EventResponse::Ignored;
718 }
719 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
720 return EventResponse::Ignored;
721 };
722 let mult = if modifiers.shift() { 10 } else { 1 };
723 let delta = match key {
724 Key::ArrowUp => mult,
725 Key::ArrowDown => -mult,
726 Key::PageUp => 10 * mult,
727 Key::PageDown => -10 * mult,
728 _ => return EventResponse::Ignored,
729 };
730 step_for_key(delta, ctx_evt);
731 EventResponse::Handled
732 });
733 ctx.apply_self_handlers(handlers);
734
735 let self_id = ctx.self_id();
738 self.value.bind_to(
739 self_id,
740 ctx.binding_registry(),
741 teksilo_core::binding::BindingLevel::AccessibilityOnly,
742 );
743
744 vec![root_id]
745 }
746
747 fn layout_response(
748 &self,
749 proposal: SizeProposal,
750 ctx: &LayoutContext,
751 ) -> teksilo_core::widget::LayoutResponse {
752 match self.root_child_id {
756 Some(id) => ctx
757 .child_layout_response(id, proposal)
758 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
759 None => proposal.resolve(0.0, 0.0).into(),
760 }
761 }
762
763 fn place_children(
764 &self,
765 bounds: Rect,
766 _proposal: SizeProposal,
767 children: &mut [WidgetPlacement],
768 _ctx: &LayoutContext,
769 ) {
770 for child in children.iter_mut() {
771 child.origin = bounds.origin();
772 child.size = bounds.size();
773 }
774 }
775
776 fn children(&self) -> Vec<WidgetId> {
777 self.root_child_id.into_iter().collect()
778 }
779
780 fn accessibility_proxy(&self) -> Option<WidgetId> {
783 self.field_id.get()
784 }
785
786 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
787 builder.set_role(Role::TimeInput);
788 if let Some(ref label) = self.label {
789 builder.set_name(label.resolve_now());
790 } else {
791 builder.set_name(resolve_message_widget("time-edit-name", &[]));
792 }
793 match self.value.get() {
794 Some(t) => {
795 builder.set_value(format!(
796 "{:02}:{:02}:{:02}",
797 t.hour(),
798 t.minute(),
799 t.second()
800 ));
801 }
802 None => {
803 if !self.placeholder.resolve_now().is_empty() {
804 builder.set_placeholder(self.placeholder.resolve_now());
805 } else {
806 builder.set_placeholder(resolve_message_widget("time-edit-placeholder", &[]));
807 }
808 }
809 }
810 if self.read_only {
812 builder.set_read_only();
813 }
814 builder.add_action(Action::Focus);
815 }
819}
820
821pub(crate) fn clamp_time(t: Time, min: Option<Time>, max: Option<Time>) -> Time {
822 let t = match min {
823 Some(min) if t < min => min,
824 _ => t,
825 };
826 match max {
827 Some(max) if t > max => max,
828 _ => t,
829 }
830}
831
832pub(crate) fn build_time_validator(
839 pattern: Rc<ParsedPattern>,
840 min: Option<Time>,
841 max: Option<Time>,
842 behavior: ValidationBehavior,
843) -> crate::primitives::text_input_field::ValidatorFn {
844 Rc::new(move |raw: &str| -> ValidationOutcome {
845 let trimmed = raw.trim();
846 if trimmed.is_empty() {
847 return ValidationOutcome::Valid;
848 }
849 if let Some(ParsedValue::Time(t)) = parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
850 let clamped = clamp_time(t, min, max);
851 let formatted = format_value(&pattern, None, Some(clamped));
852 if formatted == trimmed && clamped == t {
853 return ValidationOutcome::Valid;
854 }
855 return ValidationOutcome::Corrected {
856 corrected: formatted.clone(),
857 message: localized(move || {
858 resolve_message_widget(
859 "validation-corrected-to",
860 &[("value", formatted.clone().into())],
861 )
862 }),
863 };
864 }
865 if behavior == ValidationBehavior::AutoCorrect
866 && let Some((corrected, msg)) = try_clamp_time_recovery(&pattern, trimmed, min, max)
867 {
868 return ValidationOutcome::Corrected {
869 corrected,
870 message: msg,
871 };
872 }
873 ValidationOutcome::Invalid {
874 message: localized(move || {
875 resolve_message_widget("time-edit-validation-not-a-time", &[])
876 }),
877 }
878 })
879}
880
881pub(crate) fn try_clamp_time_recovery(
886 pattern: &ParsedPattern,
887 raw: &str,
888 min: Option<Time>,
889 max: Option<Time>,
890) -> Option<(String, LocalizedString)> {
891 use crate::common::datetime::pattern::{PatternToken, SegmentKind};
892 let mut cursor = raw;
893 let mut hour24: Option<i8> = None;
894 let mut hour12: Option<i8> = None;
895 let mut minute: Option<i8> = None;
896 let mut second: Option<i8> = None;
897 let mut period: Option<i8> = None;
898 let mut clamp_notes: Vec<LocalizedString> = Vec::new();
899
900 for token in &pattern.tokens {
901 if cursor.is_empty() {
902 break;
903 }
904 match token {
905 PatternToken::Literal(lit) => {
906 if let Some(rest) = cursor.strip_prefix(lit.as_str()) {
907 cursor = rest;
908 } else if lit.starts_with(cursor) {
909 cursor = "";
910 } else {
911 return None;
912 }
913 }
914 PatternToken::Segment(kind) => {
915 if matches!(kind, SegmentKind::Period) {
916 let first = cursor.chars().next()?;
917 let upper = first.to_ascii_uppercase();
918 let consumed = cursor.chars().next().map(|c| c.len_utf8()).unwrap_or(0);
919 let after_first = &cursor[consumed..];
920 let consumed2 = after_first
921 .chars()
922 .next()
923 .filter(|c| c.is_ascii_alphabetic())
924 .map(|c| c.len_utf8())
925 .unwrap_or(0);
926 cursor = &after_first[consumed2..];
927 period = Some(if upper == 'P' { 1 } else { 0 });
928 continue;
929 }
930 let max_d = kind.max_digits();
931 if max_d == 0 {
932 continue;
933 }
934 let mut end = 0usize;
935 for (i, ch) in cursor.char_indices() {
936 if ch.is_ascii_digit() && end < max_d {
937 end = i + ch.len_utf8();
938 } else {
939 break;
940 }
941 }
942 if end == 0 {
943 return None;
944 }
945 let digits = &cursor[..end];
946 cursor = &cursor[end..];
947 let raw_v: i32 = digits.parse().ok()?;
948 let (lo, hi) = kind.value_range().unwrap_or((i32::MIN, i32::MAX));
949 let clamped = raw_v.clamp(lo, hi);
950 if clamped != raw_v {
951 let segment_key = match kind {
952 SegmentKind::Hour24
953 | SegmentKind::Hour24Short
954 | SegmentKind::Hour12
955 | SegmentKind::Hour12Short => "validation-segment-hour",
956 SegmentKind::Minute | SegmentKind::MinuteShort => {
957 "validation-segment-minute"
958 }
959 SegmentKind::Second | SegmentKind::SecondShort => {
960 "validation-segment-second"
961 }
962 _ => "validation-segment-value",
963 };
964 let label = resolve_message_widget(segment_key, &[]);
965 clamp_notes.push(localized(move || {
966 resolve_message_widget(
967 "validation-segment-clamped",
968 &[
969 ("segment", label.clone().into()),
970 ("raw", (raw_v as i64).into()),
971 ("clamped", (clamped as i64).into()),
972 ],
973 )
974 }));
975 }
976 match kind {
977 SegmentKind::Hour24 | SegmentKind::Hour24Short => hour24 = Some(clamped as i8),
978 SegmentKind::Hour12 | SegmentKind::Hour12Short => hour12 = Some(clamped as i8),
979 SegmentKind::Minute | SegmentKind::MinuteShort => minute = Some(clamped as i8),
980 SegmentKind::Second | SegmentKind::SecondShort => second = Some(clamped as i8),
981 _ => {}
982 }
983 }
984 }
985 }
986
987 let hour = match (hour24, hour12, period) {
988 (Some(h), _, _) => h,
989 (None, Some(h12), Some(p)) => (h12 % 12) + if p == 1 { 12 } else { 0 },
990 (None, Some(h12), None) => h12 % 12,
991 (None, None, _) => return None,
992 };
993 let t = Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0).ok()?;
994 let final_t = clamp_time(t, min, max);
995 if final_t != t {
996 clamp_notes.push(localized(move || {
997 resolve_message_widget("validation-clamped-to-range", &[])
998 }));
999 }
1000 let formatted = format_value(pattern, None, Some(final_t));
1001 let formatted_for_msg = formatted.clone();
1002 let message = if clamp_notes.is_empty() {
1003 localized(move || {
1004 resolve_message_widget(
1005 "validation-corrected-to",
1006 &[("value", formatted_for_msg.clone().into())],
1007 )
1008 })
1009 } else {
1010 localized(move || {
1014 let notes_str: String = clamp_notes
1015 .iter()
1016 .map(|n| n.resolve_now())
1017 .collect::<Vec<_>>()
1018 .join(", ");
1019 resolve_message_widget(
1020 "validation-corrected-with-notes",
1021 &[("notes", notes_str.into())],
1022 )
1023 })
1024 };
1025 Some((formatted, message))
1026}