Skip to main content

teksilo_widgets/
time_edit.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TimeEdit` — text input for time-of-day, bound to `Signal<Option<Time>>`.
5//!
6//! Single-line editable time field with strftime-pattern parse/format
7//! and optional 12h/24h mode + AM/PM. Same compositional pattern as
8//! [`DateEdit`](crate::date_edit::DateEdit) (TextInputField + commit on
9//! Enter/blur + step keys), without a popover (desktop convention is no
10//! graphical time picker).
11//!
12//! # Behaviour
13//!
14//! - **Value binding**: `Signal<Option<Time>>` — `None` shows the
15//!   placeholder.
16//! - **Pattern**: 24h default `%H:%M`; 12h is `%I:%M %p`. Override
17//!   via `format_pattern`. Add seconds with
18//!   `seconds(SecondsMode::Editable)`.
19//! - **Keyboard** (preview-pass on the wrapper):
20//!   - Arrow Up / Down → ±`step_minutes`
21//!   - PageUp / PageDown → ±60 minutes
22//!   - Shift+ on either → ×10 multiplier (×600 max so values stay sane)
23//!
24//! # Accessibility
25//!
26//! - Container — `Role::TimeInput` with `set_value` formatted as
27//!   `HH:MM:SS` and `set_label` from `.label()`.
28//! - Underlying TextInputField keeps `Role::TextInput` so AT knows
29//!   it's editable.
30//!
31//! ```ignore
32//! use teksilo_core::signal::Signal;
33//! use teksilo_widgets::time_edit::{TimeEdit, TimeFormat, SecondsMode};
34//!
35//! let value = Signal::new(None);
36//! let _field = TimeEdit::new(value)
37//!     .format(TimeFormat::Hour24)
38//!     .seconds(SecondsMode::Hidden);
39//! ```
40
41#[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/// 12h vs 24h time formatting.
68///
69/// Used with [`TimeEdit::format`] to lock the clock style independently of the locale default.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum TimeFormat {
72    /// 24-hour clock (default — `%H:%M`).
73    #[default]
74    Hour24,
75    /// 12-hour clock with AM/PM segment (`%I:%M %p`).
76    Hour12,
77}
78
79/// Whether the seconds segment is shown in [`TimeEdit`].
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum SecondsMode {
82    /// Hide the seconds segment (default).
83    #[default]
84    Hidden,
85    /// Show and edit the seconds segment.
86    Editable,
87}
88
89type OnValueChanged = Rc<dyn Fn(Option<Time>, &mut EventContext)>;
90
91/// Single-line editable time-of-day field.
92///
93/// See the [module documentation](self) for full behaviour, pattern,
94/// and keyboard details.
95pub struct TimeEdit {
96    value: Signal<Option<Time>>,
97    /// Set by `::required(Signal<Time>)` — wired into `ctx.effect()`
98    /// in `build()` so observer handles outlive construction.
99    required_source: Option<Signal<Time>>,
100    /// Explicit 12h/24h override. `None` (default) means "derive from
101    /// the current locale" via `prefers_12_hour_clock`. Set via
102    /// [`Self::format`] to lock a specific clock for the field.
103    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 state, static or reactive; forwarded to the arena and the
111    /// inner `TextInput` at build time.
112    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    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
124    /// with the rich / composite slots — every setter clears the other two so
125    /// the last call wins.
126    tooltip_text: Option<LocalizedString>,
127    /// Optional rich tooltip source (registry key or inline content).
128    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
129    /// Optional composite tooltip body (arbitrary widget tree).
130    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    /// Construct bound to `value` (`None` = empty field; `Some(t)` = pre-filled time).
144    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    /// Per-call DateEditStyle override (shared with DateEdit family).
173    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    /// Construct with a **required** (non-nullable) `Signal<Time>`. The field
179    /// never shows `None`; the signal and the internal `Option` are kept in sync.
180    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    /// Lock the field to a specific clock (12h or 24h). When this
188    /// builder is *not* called, the field defaults to the user's
189    /// current locale via `prefers_12_hour_clock` (12h for en-US /
190    /// en-CA / en-AU / en-NZ / en-PH / en-IN / en-PK; 24h elsewhere).
191    pub fn format(mut self, f: TimeFormat) -> Self {
192        self.format = Some(f);
193        self
194    }
195
196    /// Show or hide the seconds segment. Default: [`SecondsMode::Hidden`].
197    pub fn seconds(mut self, mode: SecondsMode) -> Self {
198        self.seconds = mode;
199        self
200    }
201
202    /// Override the strftime-subset format pattern (e.g. `"%H:%M:%S"`).
203    /// Bypasses the locale-derived and `format`-derived defaults entirely.
204    pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
205        self.pattern_override = Some(p.into());
206        self
207    }
208
209    /// Clamp the accepted value to at or after `t` (inclusive).
210    pub fn min_time(mut self, t: Time) -> Self {
211        self.min_time = Some(t);
212        self
213    }
214
215    /// Clamp the accepted value to at or before `t` (inclusive).
216    pub fn max_time(mut self, t: Time) -> Self {
217        self.max_time = Some(t);
218        self
219    }
220
221    /// Set the ArrowUp / ArrowDown step in minutes. Default: 1. Must be ≥ 1.
222    pub fn step_minutes(mut self, n: u32) -> Self {
223        self.step_minutes = n.max(1);
224        self
225    }
226
227    /// Text shown when the field is empty (value is `None`).
228    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    /// Set the enabled state, statically or reactively. Forwarded to the
235    /// arena at build time.
236    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
237        self.enabled = enabled.into();
238        self
239    }
240
241    /// Allow display-only mode: text is selectable but not editable.
242    pub fn read_only(mut self, read_only: bool) -> Self {
243        self.read_only = read_only;
244        self
245    }
246
247    /// How parse failures are surfaced. See
248    /// [`ValidationBehavior`].
249    pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
250        self.validation_behavior = behavior;
251        self
252    }
253
254    /// How the widget claims horizontal space. See
255    /// [`WidthPolicy`](crate::date_edit::WidthPolicy). Default
256    /// `Default` (natural mask-derived width).
257    pub fn width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
258        self.width_policy = policy;
259        self
260    }
261
262    /// Reactive handle on the live validation feedback.
263    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
264        self.feedback.clone()
265    }
266
267    /// Set the accessible label for the field (announced by screen readers).
268    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    /// Callback invoked on every committed value change with the new
275    /// `Option<Time>` and a live `EventContext`.
276    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    /// Attach a plain single-line tooltip shown after a hover delay. Clears
285    /// any previously set rich or composite tooltip (last call wins).
286    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    /// Attach a rich tooltip identified by a registry key. Clears any
294    /// previously set plain or composite tooltip (last call wins).
295    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    /// Attach an inline rich tooltip from a [`crate::tooltip::TooltipContent`]
303    /// value. Clears any previously set plain or composite tooltip (last call wins).
304    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    /// Attach a composite tooltip whose body is an arbitrary widget tree.
312    /// Clears any previously set plain or rich tooltip (last call wins).
313    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    /// The bound value signal — the same `Signal` passed to [`Self::new`].
321    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
333/// Resolve the strftime-subset pattern for the given clock + seconds
334/// mode. `pub(crate)` so `DateTimeEdit` can share TimeEdit's pattern
335/// derivation rules without duplicating the matcher.
336pub(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        // required-source mirror via ctx.effect (see DateEdit::build
348        // for the rationale — observers' RAII handles can't outlive
349        // construction).
350        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        // Forward the enabled state into the arena; see IconButton.
373        ctx.enabled_when(self_id, self.enabled.clone());
374        let enabled = self.enabled.clone();
375        let read_only = self.read_only;
376
377        // Resolve clock format: explicit override → locale default.
378        // A locale switch must re-derive the 12-vs-24-hour clock: it is read from
379        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
380        // only calls `mark_all_dirty` (layout + paint), which never re-runs
381        // `build()`. Without this binding the widget keeps rendering with
382        // the pattern of whatever locale was active when it was first
383        // built. Bound at `Rebuild` for the same reason `Calendar` binds
384        // the text scale there — the value is a build-time constant, so a
385        // relayout cannot pick it up.
386        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        // Seed text from current value.
410        {
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        // External writes → reformat (skip while focused).
419        {
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        // ── Validator ─────────────────────────────────────────
438        // Mirrors DateEdit's design: pure classification; the
439        // chained on_blur callback below re-parses and updates the
440        // bound value signal.
441        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        // Commit-side: read (now-corrected) text and update value.
485        // Skips on Invalid so the user's typed text stays visible.
486        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        // No standalone ±minute-step closure — segment-aware
516        // stepping replaces the pre-segment behaviour. The
517        // `step_minutes` builder is kept on the public surface for
518        // callers that pre-configured it (it now functions as a
519        // hint for future per-segment custom steps; today the segment
520        // step is always ±1 unit / ±10 with shift / ±10 / ±100 on
521        // page keys).
522        let _ = step_minutes;
523
524        // ── TextInput composite ───────────────────────────────
525        // Same trick as DateEdit: the framing, padding, validation
526        // strip, and focus-driven border all live in TextInput. We
527        // just pass the time-shaped configuration (pattern-derived
528        // input mask, validator, char filter, commit handlers) and
529        // wrap with a min-width floor so the field sits at the
530        // editor's design width.
531        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        // Apply width policy. Default → natural mask-derived width.
584        // Fill → wrap in intrinsic-respecting Expand so the field
585        // stretches to its parent's offered width while still
586        // reporting natural width when unconstrained.
587        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        // ── Tooltip attachment ────────────────────────────────
607        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        // ── Segment-stepping helper ───────────────────────────
619        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                // Restore the caret — see the matching note in
639                // `date_edit::DateEdit::build` for the rationale.
640                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        // ── Self handlers: focus_within + segment-step keys ────
649        // Self-attached `on_key_preview` claims arrow / page keys
650        // BEFORE the focused field's `on_key`. Step targets the
651        // segment under the caret (hour/minute/second/period). Shift
652        // multiplies the unit by 10 for power-user sweeps.
653        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                // `enabled` gating is redundant here: a disabled TimeEdit's
658                // arena-disabled state cascades to the focused inner field,
659                // and `arena.is_enabled(target)` already gates the whole
660                // preview dispatch before this closure runs. `read_only` has
661                // no arena equivalent, so it still needs an explicit check.
662                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        // Bind value at AccessibilityOnly so the wrapper's set_value
682        // refreshes when the bound time changes.
683        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        // Forward the full LayoutResponse from the inner widget so the
699        // flex from `WidthPolicy::Fill`'s Expand wrapper survives. See
700        // the matching note in `date_edit::DateEdit::layout_response`.
701        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        // Framework a11y walker sets `set_disabled` from arena state.
751        if self.read_only {
752            builder.set_read_only();
753        }
754        builder.add_action(Action::Focus);
755        // SetValue is advertised on the inner field (overridden to
756        // Role::TimeInput). Wrapper duplicating it would route AT-
757        // invoked SetValue through both nodes.
758    }
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
772/// Build a time-validator closure suitable for plugging into
773/// `TextInputField::validator(...)`. Mirrors
774/// [`crate::date_edit::build_date_validator`] in shape: lenient
775/// strict-parse → clamp-recovery → reject. Used by `TimeEdit` itself
776/// AND by `DateTimeEdit`'s time half so both share the same parsing
777/// semantics without duplicating the closure body.
778pub(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
821/// AutoCorrect recovery for time inputs. Walks the pattern, extracts
822/// per-segment digit runs, clamps each value to its valid range
823/// (hour → 0..=23 in 24h or 1..=12 in 12h, minute/second → 0..=59),
824/// and re-constructs. The AM/PM segment is parsed permissively.
825pub(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        // For the notes case, we need to resolve all notes and join them.
951        // This requires a bit more work since we can't join LocalizedStrings directly.
952        // We'll resolve them at display time.
953        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}