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): the step is
20//!   *segment-relative* — it moves the field under the caret (hour,
21//!   minute, second or AM/PM), which is what `QDateTimeEdit` does.
22//!   - Arrow Up / Down → ±1 unit of that segment; Shift+ → ±10.
23//!   - PageUp / PageDown → ±10 units; Shift+ → ±100.
24//!   - Hours wrap within the day and minutes and seconds within the
25//!     hour and minute, so a sweep never rolls the value over.
26//!
27//! # Accessibility
28//!
29//! - Container — `Role::TimeInput` with `set_value` formatted as
30//!   `HH:MM:SS` and `set_label` from `.label()`.
31//! - Underlying TextInputField keeps `Role::TextInput` so AT knows
32//!   it's editable.
33//!
34//! ```ignore
35//! use teksilo_core::signal::Signal;
36//! use teksilo_widgets::time_edit::{TimeEdit, TimeFormat, SecondsMode};
37//!
38//! let value = Signal::new(None);
39//! let _field = TimeEdit::new(value)
40//!     .format(TimeFormat::Hour24)
41//!     .seconds(SecondsMode::Hidden);
42//! ```
43//!
44//! ## Touch and pen
45//!
46//! See [`DateEdit`](crate::date_edit)'s "Touch and pen" section: the whole
47//! date/time family carries `focus_within` and `on_key_preview` only, and its
48//! pointer surface is the embedded field, the trigger button and the popover.
49
50#[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/// 12h vs 24h time formatting.
77///
78/// Used with [`TimeEdit::format`] to lock the clock style independently of the locale default.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum TimeFormat {
81    /// 24-hour clock (default — `%H:%M`).
82    #[default]
83    Hour24,
84    /// 12-hour clock with AM/PM segment (`%I:%M %p`).
85    Hour12,
86}
87
88/// Whether the seconds segment is shown in [`TimeEdit`].
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum SecondsMode {
91    /// Hide the seconds segment (default).
92    #[default]
93    Hidden,
94    /// Show and edit the seconds segment.
95    Editable,
96}
97
98type OnValueChanged = Rc<dyn Fn(Option<Time>, &mut EventContext)>;
99
100/// Single-line editable time-of-day field.
101///
102/// See the [module documentation](self) for full behaviour, pattern,
103/// and keyboard details.
104pub struct TimeEdit {
105    value: Signal<Option<Time>>,
106    /// Set by `::required(Signal<Time>)` — wired into `ctx.effect()`
107    /// in `build()` so observer handles outlive construction.
108    required_source: Option<Signal<Time>>,
109    /// Explicit 12h/24h override. `None` (default) means "derive from
110    /// the current locale" via `prefers_12_hour_clock`. Set via
111    /// [`Self::format`] to lock a specific clock for the field.
112    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 state, static or reactive; forwarded to the arena and the
120    /// inner `TextInput` at build time.
121    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    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
133    /// with the rich / composite slots — every setter clears the other two so
134    /// the last call wins.
135    tooltip_text: Option<LocalizedString>,
136    /// Optional rich tooltip source (registry key or inline content).
137    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
138    /// Optional composite tooltip body (arbitrary widget tree).
139    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    /// Construct bound to `value` (`None` = empty field; `Some(t)` = pre-filled time).
153    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    /// Per-call DateEditStyle override (shared with DateEdit family).
182    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    /// Construct with a **required** (non-nullable) `Signal<Time>`. The field
188    /// never shows `None`; the signal and the internal `Option` are kept in sync.
189    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    /// Lock the field to a specific clock (12h or 24h). When this
197    /// builder is *not* called, the field defaults to the user's
198    /// current locale via `prefers_12_hour_clock` (12h for en-US /
199    /// en-CA / en-AU / en-NZ / en-PH / en-IN / en-PK; 24h elsewhere).
200    pub fn format(mut self, f: TimeFormat) -> Self {
201        self.format = Some(f);
202        self
203    }
204
205    /// Show or hide the seconds segment. Default: [`SecondsMode::Hidden`].
206    pub fn seconds(mut self, mode: SecondsMode) -> Self {
207        self.seconds = mode;
208        self
209    }
210
211    /// Override the strftime-subset format pattern (e.g. `"%H:%M:%S"`).
212    /// Bypasses the locale-derived and `format`-derived defaults entirely.
213    pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
214        self.pattern_override = Some(p.into());
215        self
216    }
217
218    /// Clamp the accepted value to at or after `t` (inclusive).
219    pub fn min_time(mut self, t: Time) -> Self {
220        self.min_time = Some(t);
221        self
222    }
223
224    /// Clamp the accepted value to at or before `t` (inclusive).
225    pub fn max_time(mut self, t: Time) -> Self {
226        self.max_time = Some(t);
227        self
228    }
229
230    /// Set the ArrowUp / ArrowDown step in minutes. Default: 1. Must be ≥ 1.
231    ///
232    /// **Currently inert.** Segment-aware stepping replaced the older
233    /// whole-value step, and it moves the field under the caret by one of
234    /// *that segment's* units rather than by a fixed number of minutes. The
235    /// builder is kept on the public surface so callers that already
236    /// configured it still compile, and as the hook a future per-segment
237    /// custom step would use; it has no effect today.
238    pub fn step_minutes(mut self, n: u32) -> Self {
239        self.step_minutes = n.max(1);
240        self
241    }
242
243    /// Text shown when the field is empty (value is `None`).
244    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    /// Set the enabled state, statically or reactively. Forwarded to the
251    /// arena at build time.
252    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
253        self.enabled = enabled.into();
254        self
255    }
256
257    /// Allow display-only mode: text is selectable but not editable.
258    pub fn read_only(mut self, read_only: bool) -> Self {
259        self.read_only = read_only;
260        self
261    }
262
263    /// How parse failures are surfaced. See
264    /// [`ValidationBehavior`].
265    pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
266        self.validation_behavior = behavior;
267        self
268    }
269
270    /// How the widget claims horizontal space. See
271    /// [`WidthPolicy`](crate::date_edit::WidthPolicy). Default
272    /// `Default` (natural mask-derived width).
273    pub fn width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
274        self.width_policy = policy;
275        self
276    }
277
278    /// Reactive handle on the live validation feedback.
279    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
280        self.feedback.clone()
281    }
282
283    /// Set the accessible label for the field (announced by screen readers).
284    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    /// Callback invoked on every committed value change with the new
291    /// `Option<Time>` and a live `EventContext`.
292    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    /// Attach a plain single-line tooltip shown after a hover delay. Clears
301    /// any previously set rich or composite tooltip (last call wins).
302    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    /// Attach a rich tooltip identified by a registry key. Clears any
310    /// previously set plain or composite tooltip (last call wins).
311    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    /// Attach an inline rich tooltip from a [`crate::tooltip::TooltipContent`]
319    /// value. Clears any previously set plain or composite tooltip (last call wins).
320    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    /// Attach a composite tooltip whose body is an arbitrary widget tree.
328    /// Clears any previously set plain or rich tooltip (last call wins).
329    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    /// The bound value signal — the same `Signal` passed to [`Self::new`].
337    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
349/// Resolve the strftime-subset pattern for the given clock + seconds
350/// mode. `pub(crate)` so `DateTimeEdit` can share TimeEdit's pattern
351/// derivation rules without duplicating the matcher.
352pub(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        // required-source mirror via ctx.effect (see DateEdit::build
364        // for the rationale — observers' RAII handles can't outlive
365        // construction).
366        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        // Forward the enabled state into the arena; see IconButton.
389        ctx.enabled_when(self_id, self.enabled.clone());
390        let enabled = self.enabled.clone();
391        let read_only = self.read_only;
392
393        // Resolve clock format: explicit override → locale default.
394        // A locale switch must re-derive the 12-vs-24-hour clock: it is read from
395        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
396        // only calls `mark_all_dirty` (layout + paint), which never re-runs
397        // `build()`. Without this binding the widget keeps rendering with
398        // the pattern of whatever locale was active when it was first
399        // built. Bound at `Rebuild` for the same reason `Calendar` binds
400        // the text scale there — the value is a build-time constant, so a
401        // relayout cannot pick it up.
402        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        // Seed text from current value.
426        {
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        // External writes → reformat (skip while focused).
435        {
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        // ── Validator ─────────────────────────────────────────
454        // Mirrors DateEdit's design: pure classification; the
455        // chained on_blur callback below re-parses and updates the
456        // bound value signal.
457        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        // Commit-side: read (now-corrected) text and update value.
501        // Skips on Invalid so the user's typed text stays visible.
502        // Returns whether the text committed — see `DateEdit`'s twin.
503        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        // No standalone ±minute-step closure — segment-aware
534        // stepping replaces the pre-segment behaviour. The
535        // `step_minutes` builder is kept on the public surface for
536        // callers that pre-configured it (it now functions as a
537        // hint for future per-segment custom steps; today the segment
538        // step is always ±1 unit / ±10 with shift / ±10 / ±100 on
539        // page keys).
540        let _ = step_minutes;
541
542        // ── TextInput composite ───────────────────────────────
543        // Same trick as DateEdit: the framing, padding, validation
544        // strip, and focus-driven border all live in TextInput. We
545        // just pass the time-shaped configuration (pattern-derived
546        // input mask, validator, char filter, commit handlers); the
547        // pattern-derived mask is what sits the field at the editor's
548        // design width.
549        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            // An assistive-technology `SetValue` on the inner text node is a
554            // finished edit, not a keystroke: push the string the technology
555            // set and run the same commit `Enter` runs, or the typed value
556            // stays stale behind a display nothing ever parses.
557            .on_access_set_value({
558                let text_signal = self.text_signal.clone();
559                let commit = commit.clone();
560                // The commit's verdict is the technology's answer: an
561                // unparseable string leaves the value where it was, and
562                // saying `Handled` there would report a write that never
563                // landed.
564                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        // Apply width policy. Default → natural mask-derived width.
622        // Fill → wrap in intrinsic-respecting Expand so the field
623        // stretches to its parent's offered width while still
624        // reporting natural width when unconstrained.
625        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        // ── Tooltip attachment ────────────────────────────────
645        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        // ── Segment-stepping helper ───────────────────────────
657        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                // Restore the caret — see the matching note in
677                // `date_edit::DateEdit::build` for the rationale.
678                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        // ── Self handlers: focus_within + segment-step keys ────
687        // Self-attached `on_key_preview` claims arrow / page keys
688        // BEFORE the focused field's `on_key`. Step targets the
689        // segment under the caret (hour/minute/second/period). Shift
690        // multiplies the unit by 10 for power-user sweeps.
691        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                // `enabled` gating is redundant here: a disabled TimeEdit's
696                // arena-disabled state cascades to the focused inner field,
697                // and `arena.is_enabled(target)` already gates the whole
698                // preview dispatch before this closure runs. `read_only` has
699                // no arena equivalent, so it still needs an explicit check.
700                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        // Bind value at AccessibilityOnly so the wrapper's set_value
720        // refreshes when the bound time changes.
721        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        // Forward the full LayoutResponse from the inner widget so the
737        // flex from `WidthPolicy::Fill`'s Expand wrapper survives. See
738        // the matching note in `date_edit::DateEdit::layout_response`.
739        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        // Framework a11y walker sets `set_disabled` from arena state.
789        if self.read_only {
790            builder.set_read_only();
791        }
792        builder.add_action(Action::Focus);
793        // SetValue is advertised on the inner field (Role::TextInput).
794        // Wrapper duplicating it would route AT-invoked SetValue through
795        // both nodes.
796    }
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
810/// Build a time-validator closure suitable for plugging into
811/// `TextInputField::validator(...)`. Mirrors
812/// [`crate::date_edit::build_date_validator`] in shape: lenient
813/// strict-parse → clamp-recovery → reject. Used by `DateTimeEdit`'s time
814/// half; `TimeEdit::build` still inlines its own copy of the same closure
815/// body, so the two have to be kept in step.
816pub(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
859/// AutoCorrect recovery for time inputs. Walks the pattern, extracts
860/// per-segment digit runs, clamps each value to its valid range
861/// (hour → 0..=23 in 24h or 1..=12 in 12h, minute/second → 0..=59),
862/// and re-constructs. The AM/PM segment is parsed permissively.
863pub(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        // For the notes case, we need to resolve all notes and join them.
989        // This requires a bit more work since we can't join LocalizedStrings directly.
990        // We'll resolve them at display time.
991        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}