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. It is where focus lands, so it carries the same name
33//!   (the `.label()`, or "Time"), and it is the composite's
34//!   [`accessibility_proxy`](teksilo_core::widget::Widget::accessibility_proxy):
35//!   what an application gives the `TimeEdit` (`access_label`,
36//!   `access_described_by`) lands on the field. As on `DateEdit`, the
37//!   field's own name is read before a `labelled_by` relation, so name a
38//!   time field in a form with `.label()`.
39//!
40//! ```ignore
41//! use teksilo_core::signal::Signal;
42//! use teksilo_widgets::time_edit::{TimeEdit, TimeFormat, SecondsMode};
43//!
44//! let value = Signal::new(None);
45//! let _field = TimeEdit::new(value)
46//!     .format(TimeFormat::Hour24)
47//!     .seconds(SecondsMode::Hidden);
48//! ```
49//!
50//! ## Touch and pen
51//!
52//! See [`DateEdit`](crate::date_edit)'s "Touch and pen" section: the whole
53//! date/time family carries `focus_within` and `on_key_preview` only, and its
54//! pointer surface is the embedded field, the trigger button and the popover.
55
56#[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/// 12h vs 24h time formatting.
83///
84/// Used with [`TimeEdit::format`] to lock the clock style independently of the locale default.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum TimeFormat {
87    /// 24-hour clock (default — `%H:%M`).
88    #[default]
89    Hour24,
90    /// 12-hour clock with AM/PM segment (`%I:%M %p`).
91    Hour12,
92}
93
94/// Whether the seconds segment is shown in [`TimeEdit`].
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum SecondsMode {
97    /// Hide the seconds segment (default).
98    #[default]
99    Hidden,
100    /// Show and edit the seconds segment.
101    Editable,
102}
103
104type OnValueChanged = Rc<dyn Fn(Option<Time>, &mut EventContext)>;
105
106/// Single-line editable time-of-day field.
107///
108/// See the [module documentation](self) for full behaviour, pattern,
109/// and keyboard details.
110pub struct TimeEdit {
111    value: Signal<Option<Time>>,
112    /// Set by `::required(Signal<Time>)` — wired into `ctx.effect()`
113    /// in `build()` so observer handles outlive construction.
114    required_source: Option<Signal<Time>>,
115    /// Explicit 12h/24h override. `None` (default) means "derive from
116    /// the current locale" via `prefers_12_hour_clock`. Set via
117    /// [`Self::format`] to lock a specific clock for the field.
118    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 state, static or reactive; forwarded to the arena and the
126    /// inner `TextInput` at build time.
127    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    /// The inner field's id once built: the node focus lands on, which
139    /// stands for this widget (`accessibility_proxy`).
140    field_id: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
141    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
142    /// with the rich / composite slots — every setter clears the other two so
143    /// the last call wins.
144    tooltip_text: Option<LocalizedString>,
145    /// Optional rich tooltip source (registry key or inline content).
146    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
147    /// Optional composite tooltip body (arbitrary widget tree).
148    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    /// Construct bound to `value` (`None` = empty field; `Some(t)` = pre-filled time).
162    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    /// Per-call DateEditStyle override (shared with DateEdit family).
192    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    /// Construct with a **required** (non-nullable) `Signal<Time>`. The field
198    /// never shows `None`; the signal and the internal `Option` are kept in sync.
199    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    /// Lock the field to a specific clock (12h or 24h). When this
207    /// builder is *not* called, the field defaults to the user's
208    /// current locale via `prefers_12_hour_clock` (12h for en-US /
209    /// en-CA / en-AU / en-NZ / en-PH / en-IN / en-PK; 24h elsewhere).
210    pub fn format(mut self, f: TimeFormat) -> Self {
211        self.format = Some(f);
212        self
213    }
214
215    /// Show or hide the seconds segment. Default: [`SecondsMode::Hidden`].
216    pub fn seconds(mut self, mode: SecondsMode) -> Self {
217        self.seconds = mode;
218        self
219    }
220
221    /// Override the strftime-subset format pattern (e.g. `"%H:%M:%S"`).
222    /// Bypasses the locale-derived and `format`-derived defaults entirely.
223    pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
224        self.pattern_override = Some(p.into());
225        self
226    }
227
228    /// Clamp the accepted value to at or after `t` (inclusive).
229    pub fn min_time(mut self, t: Time) -> Self {
230        self.min_time = Some(t);
231        self
232    }
233
234    /// Clamp the accepted value to at or before `t` (inclusive).
235    pub fn max_time(mut self, t: Time) -> Self {
236        self.max_time = Some(t);
237        self
238    }
239
240    /// Set the ArrowUp / ArrowDown step in minutes. Default: 1. Must be ≥ 1.
241    ///
242    /// **Currently inert.** Segment-aware stepping replaced the older
243    /// whole-value step, and it moves the field under the caret by one of
244    /// *that segment's* units rather than by a fixed number of minutes. The
245    /// builder is kept on the public surface so callers that already
246    /// configured it still compile, and as the hook a future per-segment
247    /// custom step would use; it has no effect today.
248    pub fn step_minutes(mut self, n: u32) -> Self {
249        self.step_minutes = n.max(1);
250        self
251    }
252
253    /// Text shown when the field is empty (value is `None`).
254    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    /// Set the enabled state, statically or reactively. Forwarded to the
261    /// arena at build time.
262    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
263        self.enabled = enabled.into();
264        self
265    }
266
267    /// Allow display-only mode: text is selectable but not editable.
268    pub fn read_only(mut self, read_only: bool) -> Self {
269        self.read_only = read_only;
270        self
271    }
272
273    /// How parse failures are surfaced. See
274    /// [`ValidationBehavior`].
275    pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
276        self.validation_behavior = behavior;
277        self
278    }
279
280    /// How the widget claims horizontal space. See
281    /// [`WidthPolicy`](crate::date_edit::WidthPolicy). Default
282    /// `Default` (natural mask-derived width).
283    pub fn width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
284        self.width_policy = policy;
285        self
286    }
287
288    /// Reactive handle on the live validation feedback.
289    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
290        self.feedback.clone()
291    }
292
293    /// Set the accessible label for the field (announced by screen readers).
294    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    /// Callback invoked on every committed value change with the new
301    /// `Option<Time>` and a live `EventContext`.
302    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    /// Attach a plain single-line tooltip shown after a hover delay. Clears
311    /// any previously set rich or composite tooltip (last call wins).
312    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    /// Attach a rich tooltip identified by a registry key. Clears any
320    /// previously set plain or composite tooltip (last call wins).
321    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    /// Attach an inline rich tooltip from a [`crate::tooltip::TooltipContent`]
329    /// value. Clears any previously set plain or composite tooltip (last call wins).
330    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    /// Attach a composite tooltip whose body is an arbitrary widget tree.
338    /// Clears any previously set plain or rich tooltip (last call wins).
339    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    /// The bound value signal — the same `Signal` passed to [`Self::new`].
347    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
359/// Resolve the strftime-subset pattern for the given clock + seconds
360/// mode. `pub(crate)` so `DateTimeEdit` can share TimeEdit's pattern
361/// derivation rules without duplicating the matcher.
362pub(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        // required-source mirror via ctx.effect (see DateEdit::build
374        // for the rationale — observers' RAII handles can't outlive
375        // construction).
376        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        // Forward the enabled state into the arena; see IconButton.
399        ctx.enabled_when(self_id, self.enabled.clone());
400        let enabled = self.enabled.clone();
401        let read_only = self.read_only;
402
403        // Resolve clock format: explicit override → locale default.
404        // A locale switch must re-derive the 12-vs-24-hour clock: it is read from
405        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
406        // only calls `mark_all_dirty` (layout + paint), which never re-runs
407        // `build()`. Without this binding the widget keeps rendering with
408        // the pattern of whatever locale was active when it was first
409        // built. Bound at `Rebuild` for the same reason `Calendar` binds
410        // the text scale there — the value is a build-time constant, so a
411        // relayout cannot pick it up.
412        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        // Seed text from current value.
436        {
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        // External writes → reformat (skip while focused).
445        {
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        // ── Validator ─────────────────────────────────────────
464        // Mirrors DateEdit's design: pure classification; the
465        // chained on_blur callback below re-parses and updates the
466        // bound value signal.
467        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        // Commit-side: read (now-corrected) text and update value.
511        // Skips on Invalid so the user's typed text stays visible.
512        // Returns whether the text committed — see `DateEdit`'s twin.
513        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        // No standalone ±minute-step closure — segment-aware
544        // stepping replaces the pre-segment behaviour. The
545        // `step_minutes` builder is kept on the public surface for
546        // callers that pre-configured it (it now functions as a
547        // hint for future per-segment custom steps; today the segment
548        // step is always ±1 unit / ±10 with shift / ±10 / ±100 on
549        // page keys).
550        let _ = step_minutes;
551
552        // ── TextInput composite ───────────────────────────────
553        // Same trick as DateEdit: the framing, padding, validation
554        // strip, and focus-driven border all live in TextInput. We
555        // just pass the time-shaped configuration (pattern-derived
556        // input mask, validator, char filter, commit handlers); the
557        // pattern-derived mask is what sits the field at the editor's
558        // design width.
559        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            // An assistive-technology `SetValue` on the inner text node is a
564            // finished edit, not a keystroke: push the string the technology
565            // set and run the same commit `Enter` runs, or the typed value
566            // stays stale behind a display nothing ever parses.
567            .on_access_set_value({
568                let text_signal = self.text_signal.clone();
569                let commit = commit.clone();
570                // The commit's verdict is the technology's answer: an
571                // unparseable string leaves the value where it was, and
572                // saying `Handled` there would report a write that never
573                // landed.
574                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        // The field is where focus lands, so it carries the name, the
615        // default one included: only an explicit `.label()` used to reach it,
616        // and two time fields side by side were both read as "entry".
617        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        // Apply width policy. Default → natural mask-derived width.
638        // Fill → wrap in intrinsic-respecting Expand so the field
639        // stretches to its parent's offered width while still
640        // reporting natural width when unconstrained.
641        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        // ── Tooltip attachment ────────────────────────────────
661        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        // ── Segment-stepping helper ───────────────────────────
673        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                // Restore the caret — see the matching note in
693                // `date_edit::DateEdit::build` for the rationale.
694                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        // ── Self handlers: focus_within + segment-step keys ────
703        // Self-attached `on_key_preview` claims arrow / page keys
704        // BEFORE the focused field's `on_key`. Step targets the
705        // segment under the caret (hour/minute/second/period). Shift
706        // multiplies the unit by 10 for power-user sweeps.
707        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                // `enabled` gating is redundant here: a disabled TimeEdit's
712                // arena-disabled state cascades to the focused inner field,
713                // and `arena.is_enabled(target)` already gates the whole
714                // preview dispatch before this closure runs. `read_only` has
715                // no arena equivalent, so it still needs an explicit check.
716                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        // Bind value at AccessibilityOnly so the wrapper's set_value
736        // refreshes when the bound time changes.
737        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        // Forward the full LayoutResponse from the inner widget so the
753        // flex from `WidthPolicy::Fill`'s Expand wrapper survives. See
754        // the matching note in `date_edit::DateEdit::layout_response`.
755        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    /// The field stands for the time input: what an application attaches to
781    /// the `TimeEdit`'s id lands on the node that holds focus.
782    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        // Framework a11y walker sets `set_disabled` from arena state.
811        if self.read_only {
812            builder.set_read_only();
813        }
814        builder.add_action(Action::Focus);
815        // SetValue is advertised on the inner field (Role::TextInput).
816        // Wrapper duplicating it would route AT-invoked SetValue through
817        // both nodes.
818    }
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
832/// Build a time-validator closure suitable for plugging into
833/// `TextInputField::validator(...)`. Mirrors
834/// [`crate::date_edit::build_date_validator`] in shape: lenient
835/// strict-parse → clamp-recovery → reject. Used by `DateTimeEdit`'s time
836/// half; `TimeEdit::build` still inlines its own copy of the same closure
837/// body, so the two have to be kept in step.
838pub(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
881/// AutoCorrect recovery for time inputs. Walks the pattern, extracts
882/// per-segment digit runs, clamps each value to its valid range
883/// (hour → 0..=23 in 24h or 1..=12 in 12h, minute/second → 0..=59),
884/// and re-constructs. The AM/PM segment is parsed permissively.
885pub(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        // For the notes case, we need to resolve all notes and join them.
1011        // This requires a bit more work since we can't join LocalizedStrings directly.
1012        // We'll resolve them at display time.
1013        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}