Skip to main content

teksilo_widgets/
date_edit.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DateEdit` — text input + calendar popover, bound to `Signal<Option<Date>>`.
5//!
6//! A single-line editable date field. The underlying surface is a
7//! `TextInputField` displaying the formatted date; commit on Enter or
8//! blur parses the input against the active pattern, clamps to
9//! `[min_date, max_date]`, and writes the result back. A trailing
10//! calendar-icon button opens a [`Calendar`]
11//! popover anchored below the field for graphical date selection.
12//!
13//! # Behaviour
14//!
15//! - **Value binding**: `Signal<Option<Date>>` is the source of truth.
16//!   External writes re-format the text. `None` shows the placeholder.
17//! - **Pattern**: locale-derived strftime-subset (`%Y-%m-%d`,
18//!   `%m/%d/%Y`, …); override via `format_pattern`.
19//! - **Step keys** (preview-pass on the field): the step is
20//!   *segment-relative* — it moves the field under the caret (year,
21//!   month or day), which is what `QDateTimeEdit` does.
22//!   - Arrow Up / Down → ±1 unit of that segment; Shift+ → ±10.
23//!   - Page Up / Page Down → ±10 units; Shift+ → ±100.
24//!   - `Alt+ArrowDown` (or click the calendar icon) → opens the calendar
25//!     popover; `Alt+ArrowUp` closes it and `F4` toggles it.
26//!   - A chord holding `Ctrl` or `Super` is not the field's and falls
27//!     through to the application.
28//! - **Calendar popover**: dismisses on click-outside or Escape,
29//!   commits on cell click. The request carries no `fade_duration`, so
30//!   it appears and goes without a fade.
31//! - **Min / Max**: clamps on commit and on step. Out-of-range values
32//!   in the popover cell are disabled.
33//!
34//! # Accessibility
35//!
36//! - Container: `Role::DateInput`, named from the `.label()` builder, its
37//!   value the selected day in full in the tree's locale ("samedi 2 mai
38//!   2026", "Saturday, May 2, 2026"), and `set_placeholder` when the value
39//!   is `None`. The field inside shows the editable pattern; this value is
40//!   written for someone listening, not for a parser. UIA and macOS expose
41//!   it; AT-SPI carries a string value on no interface.
42//! - Calendar trigger button — `Role::Button`, named from its tooltip.
43//!   `set_has_popup(HasPopup::Grid)` and `set_expanded(open)` sit on the
44//!   container node above, not on the button.
45//! - Internally the editing surface remains a `Role::TextInput` for
46//!   AT discoverability (so screen readers know it accepts text); the
47//!   wrapper carries the DateInput role on the outer node. The field is
48//!   where focus lands, so it carries the same name, and it is the
49//!   composite's
50//!   [`accessibility_proxy`](teksilo_core::widget::Widget::accessibility_proxy):
51//!   an `access_label` or an `access_described_by` given to the `DateEdit`
52//!   lands on the field. A `labelled_by` relation (a `FormLayout` row's)
53//!   lands there too, but AccessKit reads a node's own name before its
54//!   relations, so name a date field in a form with `.label()`.
55//! - The calendar opens on the date the field holds (the text typed in it
56//!   is committed first), on its days, whatever an earlier opening left.
57//!
58//! # Example
59//!
60//! ```ignore
61//! use teksilo::widgets::{DateEdit, common::datetime::Date};
62//!
63//! let date = ctx.signal(Some(Date::constant(2026, 5, 2)));
64//! ctx.add(
65//!     DateEdit::new(date.clone())
66//!         .min_date(Date::constant(2020, 1, 1))
67//!         .max_date(Date::constant(2030, 12, 31))
68//!         .label(lit!("Birth date")),
69//! );
70//! ```
71//!
72//! ## Touch and pen
73//!
74//! Nothing to declare here, and the reason applies to the whole date/time
75//! family ([`TimeEdit`](crate::time_edit::TimeEdit),
76//! [`DateTimeEdit`](crate::date_time_edit::DateTimeEdit),
77//! [`DateRangeEdit`](crate::date_range_edit::DateRangeEdit)): the composite's
78//! own handler set carries `focus_within` and `on_key_preview` only — no tap,
79//! no drag, no scroll — so its pointer surface is entirely the embedded text
80//! field, the trigger `IconButton` and the popover `Calendar`, each of which
81//! answers for itself. In particular the family does **not** declare
82//! `touch_action(NONE)`: it produces no value from a press position, and
83//! declaring it would stop a finger scrolling a form that contains a date
84//! field.
85
86#[cfg(test)]
87mod tests;
88
89use std::rc::Rc;
90use teksilo_i18n::localized;
91
92use jiff::civil::Weekday;
93use teksilo_canvas::{Path, Point, Rect, SizeProposal};
94use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
95use teksilo_core::accesskit::{Action, HasPopup, Role};
96use teksilo_core::build_context::BuildContext;
97use teksilo_core::event::{EventResponse, Key, WidgetEvent};
98use teksilo_core::overlay::{
99    DismissBehavior, OverlayDismissCallback, OverlayPlacement, OverlayRequest,
100};
101use teksilo_core::signal::{Prop, Signal};
102use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
103use teksilo_core::widget_builder::HandlerSet;
104use teksilo_core::widget_id::WidgetId;
105use teksilo_i18n::{LanguageIdentifier, resolve_message_widget};
106
107use crate::calendar::Calendar;
108use crate::common::datetime::Date;
109use crate::common::datetime::pattern::{
110    ParseTarget, ParsedPattern, ParsedValue, PatternToken, SegmentKind, format_value,
111    mask_for_pattern, parse_value, segment_at_position, step_date_field,
112};
113use crate::common::datetime::types::{YearMonth, today_local};
114use crate::common::datetime::written::{date_locale, full_date};
115use crate::common::range_nav;
116use crate::icon_button::{IconButton, IconButtonSize};
117use crate::primitives::IconWidget;
118use crate::primitives::text_input_field::{ValidationFeedback, ValidationOutcome};
119use crate::text_input::TextInput;
120use teksilo_i18n::LocalizedString;
121
122type OnValueChanged = Rc<dyn Fn(Option<Date>, &mut EventContext)>;
123
124/// How a datetime widget claims horizontal space.
125///
126/// Shared across `DateEdit`, `TimeEdit`, `DateRangeEdit`, and
127/// `DateTimeEdit`. For the two-half widgets the policy applies to
128/// the *trailing* half only — the leading half always sizes to its
129/// mask-derived natural width so the date never reflows when only
130/// the time half changes.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum WidthPolicy {
133    /// **Default.** The widget claims its natural width: the mask-derived
134    /// empty template (`__/__/____` for ISO date, `__:__` for 24h time)
135    /// measured in the theme body font plus surrounding chrome.
136    /// The footprint stays fixed as the user types — Int UI form-density
137    /// convention. This is the [`Default`].
138    #[default]
139    Default,
140    /// The widget expands to fill the horizontal space its parent offers,
141    /// instead of capping at the natural mask width. Use inside toolbars,
142    /// inspector panels, or an `Expand::horizontal` column that should
143    /// stretch with the surrounding layout.
144    Fill,
145}
146
147/// How the date editor reacts to out-of-range or partially invalid input.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum ValidationBehavior {
150    /// Out-of-range inputs are clamped to the nearest valid value
151    /// (e.g. `12/50/2026` → `12/31/2026`) and announced via `Live::Polite`.
152    /// Matches macOS Calendar and iOS DatePicker. This is the [`Default`].
153    #[default]
154    AutoCorrect,
155    /// Out-of-range inputs are rejected with an inline error message;
156    /// the field's text is left as-typed so the user can correct it.
157    /// The bound value is unchanged until a valid date is committed.
158    /// Matches Excel / Material strict-validation patterns. Use for
159    /// high-precision contexts where silently rounding is unacceptable.
160    Reject,
161}
162
163/// Single-line date input with optional calendar popover. See the
164/// [module docs](self) for the full feature list.
165pub struct DateEdit {
166    value: Signal<Option<Date>>,
167    /// Set by `::required(Signal<Date>)` — the original non-nullable
168    /// upstream that needs to mirror with `value`. Wired via
169    /// `ctx.effect()` in `build()` so the observer handles live with
170    /// the widget rather than being dropped at construction.
171    required_source: Option<Signal<Date>>,
172    min_date: Option<Date>,
173    max_date: Option<Date>,
174    pattern: Option<String>,
175    placeholder: LocalizedString,
176    first_day_of_week: Option<Weekday>,
177    show_calendar_button: bool,
178    calendar_popover_placement: OverlayPlacement,
179    /// Enabled state, static or reactive; forwarded to the arena at
180    /// build time.
181    enabled: Prop<bool>,
182    read_only: bool,
183    /// How parse failures are surfaced. Default `AutoCorrect`.
184    validation_behavior: ValidationBehavior,
185    /// How the field claims horizontal space. Default
186    /// [`WidthPolicy::Default`] — the field sizes to its natural
187    /// mask-derived width and stays put.
188    width_policy: WidthPolicy,
189    label: Option<LocalizedString>,
190    on_value_changed: Option<OnValueChanged>,
191    /// Live feedback signal mirrored from the inner field, owned by
192    /// `DateEdit` so the wrapper's `accessibility()` and the
193    /// `ValidationStrip` below the field both bind to it.
194    feedback: Signal<ValidationFeedback>,
195    /// Live edit text driven by both user typing and programmatic
196    /// re-formatting (mirroring SpinBox's pattern).
197    text_signal: Signal<String>,
198    /// Field-focus tracker; controls whether the value reformat effect
199    /// stomps on user typing.
200    focused: Signal<bool>,
201    /// Whether the calendar popover is currently open. Drives
202    /// `set_expanded` on the trigger.
203    popover_open: Signal<bool>,
204    /// The inner field's id once built: the node focus lands on, which
205    /// stands for this widget (`accessibility_proxy`).
206    field_id: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
207    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
208    /// with the rich / composite slots — every setter clears the other two so
209    /// the last call wins.
210    tooltip_text: Option<LocalizedString>,
211    /// Optional rich tooltip source (registry key or inline content).
212    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
213    /// Optional composite tooltip body (arbitrary widget tree).
214    composite_tooltip_content: Option<Box<dyn Widget>>,
215    // Build state
216    /// Per-call DateEditStyle override. Higher precedence than the
217    /// theme-wide `style_slots.date_edit` slot.
218    style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
219    root_child_id: Option<WidgetId>,
220    calendar_id: Option<WidgetId>,
221    /// The locale the accessibility value writes the date in, resolved
222    /// from the tree's locale in `build()`, which a locale switch re-runs.
223    lang: LanguageIdentifier,
224}
225
226impl std::fmt::Debug for DateEdit {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        f.debug_struct("DateEdit")
229            .field("min", &self.min_date)
230            .field("max", &self.max_date)
231            .field("enabled", &self.enabled.get())
232            .finish_non_exhaustive()
233    }
234}
235
236impl DateEdit {
237    /// Construct a date editor bound to a nullable date signal.
238    pub fn new(value: Signal<Option<Date>>) -> Self {
239        Self {
240            value,
241            required_source: None,
242            min_date: None,
243            max_date: None,
244            pattern: None,
245            placeholder: LocalizedString::literal(String::new()),
246            first_day_of_week: None,
247            show_calendar_button: true,
248            calendar_popover_placement: OverlayPlacement::BelowPreferred,
249            enabled: Prop::Static(true),
250            read_only: false,
251            validation_behavior: ValidationBehavior::AutoCorrect,
252            width_policy: WidthPolicy::Default,
253            label: None,
254            on_value_changed: None,
255            feedback: Signal::new(ValidationFeedback::Pristine),
256            text_signal: Signal::new(String::new()),
257            focused: Signal::new(false),
258            popover_open: Signal::new(false),
259            field_id: std::rc::Rc::new(std::cell::Cell::new(None)),
260            tooltip_text: None,
261            rich_tooltip_source: None,
262            composite_tooltip_content: None,
263            style_override: None,
264            root_child_id: None,
265            calendar_id: None,
266            lang: date_locale(None),
267        }
268    }
269
270    /// Per-call style override for the date-edit chrome.
271    pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
272        self.style_override = Some(Rc::new(style));
273        self
274    }
275
276    /// Construct from a non-nullable date signal. Internally backed by
277    /// a `Signal<Option<Date>>` proxy that mirrors the source in both
278    /// directions. The placeholder is normally unused — the proxy is
279    /// initialized to `Some(value.get())`, and only goes `None`
280    /// transiently when a commit clears the text (the mirror then leaves
281    /// the source alone until the next valid commit re-establishes it).
282    pub fn required(value: Signal<Date>) -> Self {
283        let proxy: Signal<Option<Date>> = Signal::new(Some(value.get()));
284        let mut s = Self::new(proxy);
285        s.required_source = Some(value);
286        s
287    }
288
289    /// Clamp the selectable range from below. Dates earlier than `d`
290    /// are clamped up to `d` on commit and on step, and are shown as
291    /// disabled in the calendar popover.
292    pub fn min_date(mut self, d: Date) -> Self {
293        self.min_date = Some(d);
294        self
295    }
296
297    /// Clamp the selectable range from above. Dates later than `d`
298    /// are clamped down to `d` on commit and on step, and are shown as
299    /// disabled in the calendar popover.
300    pub fn max_date(mut self, d: Date) -> Self {
301        self.max_date = Some(d);
302        self
303    }
304
305    /// Override the locale-derived format pattern (strftime subset, see
306    /// `crate::common::datetime::pattern`).
307    pub fn format_pattern(mut self, pat: impl Into<String>) -> Self {
308        self.pattern = Some(pat.into());
309        self
310    }
311
312    /// Text displayed when the bound value is `None`. Defaults to empty
313    /// (no placeholder rendered).
314    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
315        let ls: LocalizedString = text.into();
316        self.placeholder = ls;
317        self
318    }
319
320    /// Override which weekday heads the calendar's column grid.
321    /// Defaults to the locale's convention if not set.
322    pub fn first_day_of_week(mut self, w: Weekday) -> Self {
323        self.first_day_of_week = Some(w);
324        self
325    }
326
327    /// Show or hide the trailing calendar-icon trigger button that opens
328    /// the calendar popover. Default `true`.
329    pub fn show_calendar_button(mut self, show: bool) -> Self {
330        self.show_calendar_button = show;
331        self
332    }
333
334    /// Override where the calendar popover appears relative to the field.
335    /// Default is [`OverlayPlacement::BelowPreferred`].
336    pub fn calendar_popover_placement(mut self, p: OverlayPlacement) -> Self {
337        self.calendar_popover_placement = p;
338        self
339    }
340
341    /// Set the enabled state, statically or reactively. Forwarded to
342    /// the arena at build time.
343    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
344        self.enabled = enabled.into();
345        self
346    }
347
348    /// Make the field read-only: text is selectable and copyable but
349    /// not editable, and step keys are suppressed.
350    pub fn read_only(mut self, read_only: bool) -> Self {
351        self.read_only = read_only;
352        self
353    }
354
355    /// How parse failures are surfaced. Default
356    /// [`ValidationBehavior::AutoCorrect`] (clamp + announce); switch
357    /// to [`ValidationBehavior::Reject`] for strict-validation form
358    /// contexts.
359    pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
360        self.validation_behavior = behavior;
361        self
362    }
363
364    /// How the widget claims horizontal space. Default
365    /// [`WidthPolicy::Default`] — the field sizes to its natural
366    /// mask-derived width. Switch to [`WidthPolicy::Fill`] to make
367    /// the field stretch to fill the parent's offered width
368    /// (toolbar / inspector pattern).
369    pub fn width_policy(mut self, policy: WidthPolicy) -> Self {
370        self.width_policy = policy;
371        self
372    }
373
374    /// Reactive handle on the live validation feedback (mirrored from
375    /// the inner field). Composites that want to render their own
376    /// feedback UI elsewhere can bind to this; the default
377    /// `ValidationStrip` slot below the field uses it internally.
378    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
379        self.feedback.clone()
380    }
381
382    /// Set the accessible label for the field (also shown by any paired
383    /// `FormLayout` label slot). Defaults to the localized "Date" string.
384    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
385        let ls: LocalizedString = label.into();
386        self.label = Some(ls);
387        self
388    }
389
390    /// Register a callback fired on every committed value change with the
391    /// new `Option<Date>` and a live `EventContext`. Fires only on
392    /// user-driven commits (typing + blur, Enter, calendar selection),
393    /// not on external writes to the bound signal.
394    pub fn on_value_changed(
395        mut self,
396        f: impl Fn(Option<Date>, &mut EventContext) + 'static,
397    ) -> Self {
398        self.on_value_changed = Some(Rc::new(f));
399        self
400    }
401
402    /// Return a clone of the bound value signal for external observation.
403    pub fn value(&self) -> Signal<Option<Date>> {
404        self.value.clone()
405    }
406
407    /// Attach a plain single-line tooltip shown after a hover delay.
408    /// Mutually exclusive with [`Self::rich_tooltip`],
409    /// [`Self::rich_tooltip_content`], and [`Self::composite_tooltip`] —
410    /// this call clears those slots.
411    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
412        self.tooltip_text = Some(text.into());
413        self.rich_tooltip_source = None;
414        self.composite_tooltip_content = None;
415        self
416    }
417
418    /// Attach a rich tooltip looked up by registry key. Mutually exclusive
419    /// with [`Self::tooltip`], [`Self::rich_tooltip_content`], and
420    /// [`Self::composite_tooltip`] — this call clears those slots.
421    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
422        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
423        self.tooltip_text = None;
424        self.composite_tooltip_content = None;
425        self
426    }
427
428    /// Attach a rich tooltip from inline content. Mutually exclusive with
429    /// [`Self::tooltip`], [`Self::rich_tooltip`], and
430    /// [`Self::composite_tooltip`] — this call clears those slots.
431    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
432        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
433        self.tooltip_text = None;
434        self.composite_tooltip_content = None;
435        self
436    }
437
438    /// Attach a composite tooltip whose body is an arbitrary widget tree.
439    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
440    /// and [`Self::rich_tooltip_content`] — this call clears those slots.
441    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
442        self.composite_tooltip_content = Some(Box::new(content));
443        self.tooltip_text = None;
444        self.rich_tooltip_source = None;
445        self
446    }
447}
448
449impl Widget for DateEdit {
450    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
451        // Wire required-source mirror via ctx.effect so the observer
452        // handles live with the widget rather than being dropped at
453        // construction. Effects auto-clean on rebuild.
454        if let Some(src) = self.required_source.clone() {
455            // Source → proxy.
456            {
457                let proxy = self.value.clone();
458                ctx.effect(&src, move |new| {
459                    if proxy.get() != Some(*new) {
460                        proxy.set(Some(*new));
461                    }
462                });
463            }
464            // Proxy → source. The proxy can hold `None` transiently
465            // (parse failure → cleared text); ignore that and let the
466            // next valid commit re-establish the value. The required
467            // contract is "always have a value upstream", which the
468            // initial seed in `::required` guarantees.
469            {
470                let src_clone = src;
471                ctx.effect(&self.value, move |v| {
472                    if let Some(d) = v
473                        && src_clone.get() != *d
474                    {
475                        src_clone.set(*d);
476                    }
477                });
478            }
479        }
480
481        let theme = ctx.theme_signal().get();
482        use crate::styles::recipe_date_edit_style as de;
483        let _ = &theme;
484        let self_id = ctx.self_id();
485        // Forward the enabled state into the arena; see IconButton.
486        ctx.enabled_when(self_id, self.enabled.clone());
487        let enabled = self.enabled.get();
488        let read_only = self.read_only;
489
490        // A locale switch must re-derive the date pattern: it is read from
491        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
492        // only calls `mark_all_dirty` (layout + paint), which never re-runs
493        // `build()`. Without this binding the widget keeps rendering with
494        // the pattern of whatever locale was active when it was first
495        // built. Bound at `Rebuild` for the same reason `Calendar` binds
496        // the text scale there — the value is a build-time constant, so a
497        // relayout cannot pick it up.
498        ctx.locale_signal().bind_to(
499            ctx.self_id(),
500            ctx.binding_registry(),
501            teksilo_core::binding::BindingLevel::Rebuild,
502        );
503
504        self.lang = date_locale(ctx.locale_signal().get().as_deref());
505
506        // Resolve pattern: explicit override → locale default.
507        let pattern_string = self.pattern.clone().unwrap_or_else(|| {
508            let tag = ctx.locale_signal().get().unwrap_or_default();
509            crate::common::datetime::format_pattern_for_locale(&tag).to_string()
510        });
511        let parsed_pattern = ParsedPattern::parse(&pattern_string)
512            .unwrap_or_else(|_| ParsedPattern::parse("%Y-%m-%d").unwrap());
513        let pattern_rc = Rc::new(parsed_pattern);
514        let placeholder = self.placeholder.clone();
515        let min = self.min_date;
516        let max = self.max_date;
517        let on_value_changed = self.on_value_changed.clone();
518
519        // Seed text from current value.
520        {
521            let init = match self.value.get() {
522                Some(d) => format_value(&pattern_rc, Some(d), None),
523                None => String::new(),
524            };
525            self.text_signal.set(init);
526        }
527
528        // External writes → reformat (skip while focused).
529        {
530            let text_signal = self.text_signal.clone();
531            let focused = self.focused.clone();
532            let pattern = pattern_rc.clone();
533            ctx.effect(&self.value, move |new_value| {
534                if focused.get() {
535                    return;
536                }
537                let formatted = match new_value {
538                    Some(d) => format_value(&pattern, Some(*d), None),
539                    None => String::new(),
540                };
541                if text_signal.get() != formatted {
542                    text_signal.set(formatted);
543                }
544            });
545        }
546
547        // ── Validator ─────────────────────────────────────────
548        // Pure classification: given raw text, return one of three
549        // outcomes. The field's wrapper writes feedback signal +
550        // re-formats text on `Corrected`. The on_blur callback
551        // (chained AFTER the validator) re-parses the (now-corrected)
552        // text and updates the bound value signal + fires the user
553        // callback with EventContext.
554        let validation_behavior = self.validation_behavior;
555        let validator: crate::primitives::text_input_field::ValidatorFn = {
556            let pattern = pattern_rc.clone();
557            Rc::new(move |raw: &str| -> ValidationOutcome {
558                let trimmed = raw.trim();
559                if trimmed.is_empty() {
560                    // Empty is valid (clears value to None on commit).
561                    return ValidationOutcome::Valid;
562                }
563                // 1. Try strict parse + reformat-compare to detect
564                //    lenient-fill normalization (e.g., "2026" →
565                //    "2026-01-01", or "2026-5" → "2026-05-01").
566                if let Some(ParsedValue::Date(d)) =
567                    parse_value(&pattern, trimmed, ParseTarget::DateOnly)
568                {
569                    let clamped = clamp_date(d, min, max);
570                    let formatted = format_value(&pattern, Some(clamped), None);
571                    if formatted == trimmed && clamped == d {
572                        return ValidationOutcome::Valid;
573                    }
574                    return ValidationOutcome::Corrected {
575                        corrected: formatted.clone(),
576                        message: localized(move || {
577                            resolve_message_widget(
578                                "validation-corrected-to",
579                                &[("value", formatted.clone().into())],
580                            )
581                        }),
582                    };
583                }
584                // 2. Strict parse failed. Try clamp-recovery: extract
585                //    each segment value, clamp out-of-range values to
586                //    their valid range, and re-construct.
587                if validation_behavior == ValidationBehavior::AutoCorrect
588                    && let Some((corrected, msg)) = try_clamp_recovery(&pattern, trimmed, min, max)
589                {
590                    return ValidationOutcome::Corrected {
591                        corrected,
592                        message: msg,
593                    };
594                }
595                // 3. Truly unparseable. Reject.
596                ValidationOutcome::Invalid {
597                    message: localized(move || {
598                        resolve_message_widget("date-edit-validation-not-a-date", &[])
599                    }),
600                }
601            })
602        };
603
604        // Commit-side effect: when the inner field's wrapper writes
605        // a Corrected outcome, the text_signal already holds the
606        // formatted-corrected text. Re-parse and sync the bound
607        // value + fire on_value_changed via the chained on_blur
608        // callback below.
609        //
610        // For Invalid: leave the typed text in the field so the user
611        // can fix it; do NOT silently revert (the user's complaint
612        // that triggered this whole feature). The bound value stays
613        // unchanged.
614        // Returns whether the text committed — `false` while the validator is
615        // unhappy, and `false` for a string the pattern cannot read. Only the
616        // assistive-technology write reads it; `Enter` and blur discard it.
617        let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
618            let value_signal = self.value.clone();
619            let text_signal = self.text_signal.clone();
620            let feedback_signal = self.feedback.clone();
621            let pattern = pattern_rc.clone();
622            let on_value_changed = on_value_changed.clone();
623            Rc::new(move |ctx_evt: &mut EventContext| {
624                let fb = feedback_signal.get();
625                if matches!(fb, ValidationFeedback::Invalid { .. }) {
626                    // Don't touch value or reformat text; let the
627                    // user fix what they typed.
628                    return false;
629                }
630                let raw = text_signal.get();
631                let trimmed = raw.trim();
632                let (new_value, accepted): (Option<Date>, bool) = if trimmed.is_empty() {
633                    (None, true)
634                } else {
635                    match parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
636                        Some(ParsedValue::Date(d)) => (Some(clamp_date(d, min, max)), true),
637                        _ => (value_signal.get(), false),
638                    }
639                };
640                if value_signal.get() != new_value {
641                    value_signal.set(new_value);
642                    if let Some(cb) = on_value_changed.as_ref() {
643                        cb(new_value, ctx_evt);
644                    }
645                }
646                accepted
647            })
648        };
649
650        // No standalone day-step closure — segment-aware stepping is
651        // installed inside the on_key_preview self handler below
652        // (replaces the pre-segment ±day stepping that used to live
653        // here).
654
655        // ── Calendar popover (pre-built dormant) ──────────────
656        // Built before the TextInput composite so the trailing-slot
657        // trigger button can capture the calendar's id.
658        let mut calendar_opening = None;
659        let calendar_id_opt = if self.show_calendar_button {
660            // Bridge signal: the calendar binds to a parallel
661            // `Signal<Option<Date>>` so its internal cell-render +
662            // arrow-key state can mutate freely; the popover commit
663            // path writes the final selection into our `value`. We
664            // keep the bridge in sync with external `value` changes
665            // via `ctx.effect` (NOT `observe()` — observers return
666            // RAII handles that get dropped at construction; effects
667            // live with the widget).
668            let calendar_temp: Signal<Option<Date>> = Signal::new(self.value.get());
669            {
670                let temp = calendar_temp.clone();
671                ctx.effect(&self.value, move |new_value| {
672                    if temp.get() != *new_value {
673                        temp.set(*new_value);
674                    }
675                });
676            }
677            let popover_open = self.popover_open.clone();
678            let value_for_cal = self.value.clone();
679            let text_signal_for_cal = self.text_signal.clone();
680            let pattern_for_cal = pattern_rc.clone();
681            let on_value_changed_for_cal = on_value_changed.clone();
682            let return_focus_to = ctx.self_id();
683            let mut calendar =
684                Calendar::single(calendar_temp.clone()).on_activate(move |d, ctx_evt| {
685                    let clamped = clamp_date(d, min, max);
686                    value_for_cal.set(Some(clamped));
687                    text_signal_for_cal.set(format_value(&pattern_for_cal, Some(clamped), None));
688                    if let Some(cb) = on_value_changed_for_cal.as_ref() {
689                        cb(Some(clamped), ctx_evt);
690                    }
691                    popover_open.set(false);
692                    ctx_evt.dismiss_self_overlay_chain();
693                    // Return focus to the DateEdit so keyboard users
694                    // are back at the trigger after committing —
695                    // matches the open path's `request_focus(calendar_id)`
696                    // and keeps the focus pointer on a sensible widget
697                    // (Tab from here lands wherever Tab would have
698                    // gone next, not at the document root).
699                    ctx_evt.request_focus(return_focus_to);
700                    ctx_evt.request_frame();
701                });
702            if let Some(min) = min {
703                calendar = calendar.min_date(min);
704            }
705            if let Some(max) = max {
706                calendar = calendar.max_date(max);
707            }
708            if let Some(fdow) = self.first_day_of_week {
709                calendar = calendar.first_day_of_week(fdow);
710            }
711            calendar_opening = Some(calendar.opening());
712            // Detached, not a child: the popup must not wake or paint with the
713            // field. `add_detached` records the ownership edge anyway, so the
714            // calendar dies with this widget and each rebuild reaps the
715            // previous one — a bare `ctx.add` stranded a ~200-widget calendar
716            // in the arena per rebuild.
717            // Built the first time the popup is opened, not on every rebuild of the
718            // field. See `teksilo_core::deferred_subtree::DeferredSubtree`.
719            let calendar_id = ctx.add_detached_deferred(self.popover_open.clone(), calendar);
720            ctx.set_dormant(calendar_id);
721            Some(calendar_id)
722        } else {
723            None
724        };
725        self.calendar_id = calendar_id_opt;
726
727        // ── Calendar trigger button (built as a value, dropped into
728        //    the TextInput's trailing slot) ──────────────────────
729        // Same Int UI `IconButton` (in embedded mode) the other
730        // datetime widgets (DateRangeEdit, DateTimeEdit) use, so the
731        // visual treatment — hover/pressed background, icon size,
732        // focus halo — stays consistent across the family.
733        //
734        // `toggle_calendar` is the one definition of "open or close the
735        // calendar", shared by the trailing icon button below and by the
736        // `Alt+ArrowDown` / `F4` chords in the key handler. `None` when
737        // `show_calendar_button(false)` built no calendar at all — the chords
738        // then fall through rather than pretending.
739        let mut toggle_calendar: Option<Rc<dyn Fn(&mut EventContext)>> = None;
740
741        let trigger_widget_opt: Option<IconButton> = if self.show_calendar_button {
742            let popover_open = self.popover_open.clone();
743            let calendar_id = calendar_id_opt.expect("calendar built when button enabled");
744            let placement = self.calendar_popover_placement.clone();
745            let self_ref = ctx.self_id();
746            let dismiss_cb: OverlayDismissCallback = {
747                let popover_open = popover_open.clone();
748                Rc::new(move |_, _| {
749                    popover_open.set(false);
750                })
751            };
752            let opening = calendar_opening
753                .clone()
754                .expect("calendar built when button enabled");
755            let value = self.value.clone();
756            let commit = commit.clone();
757            let toggle: Rc<dyn Fn(&mut EventContext)> = Rc::new({
758                let popover_open = popover_open.clone();
759                move |ctx_evt: &mut EventContext| {
760                    if popover_open.get() {
761                        popover_open.set(false);
762                        ctx_evt.dismiss_all_except_hosts();
763                    } else {
764                        // The calendar opens on the date the field holds. It
765                        // is built once and keeps its cursor, month and view
766                        // between openings, and opened wherever the last one
767                        // was left: after a step in the field, or a cursor
768                        // moved and abandoned, Enter wrote that other day
769                        // over the field's value. Text typed and not yet
770                        // committed is committed first, as leaving the field
771                        // would, since that happens only once focus moves.
772                        commit(ctx_evt);
773                        opening.open_on(value.get());
774                        popover_open.set(true);
775                        // Build the popup if this is its first open, before the overlay
776                        // below is measured against it and focus moves into it.
777                        ctx_evt.materialize_now(calendar_id);
778                        ctx_evt.activate(calendar_id);
779                        ctx_evt.show_overlay(
780                            OverlayRequest::new(
781                                calendar_id,
782                                self_ref,
783                                placement.clone(),
784                                DismissBehavior::EscapeOrClickOutside,
785                            )
786                            .on_dismiss(dismiss_cb.clone()),
787                        );
788                        // Move focus into the calendar so arrow keys
789                        // navigate cells immediately — standard date-
790                        // picker UX (macOS Calendar, JetBrains, etc.).
791                        // Without this the user must Tab through
792                        // unrelated widgets first.
793                        ctx_evt.request_focus(calendar_id);
794                    }
795                }
796            });
797            toggle_calendar = Some(toggle.clone());
798            Some(
799                IconButton::new(calendar_glyph_icon(de::CALENDAR_ICON_SIZE))
800                    .embedded()
801                    .size(IconButtonSize::Default)
802                    .enabled(enabled && !read_only)
803                    .tooltip(localized(move || {
804                        resolve_message_widget("date-edit-trigger-tooltip", &[])
805                    }))
806                    .on_activate_fn(move |ctx_evt: &mut EventContext| toggle(ctx_evt)),
807            )
808        } else {
809            None
810        };
811
812        // ── TextInput composite ───────────────────────────────
813        // Drops the date-shaped editing surface into the same frame
814        // every TextInput uses (border, padding, validation strip,
815        // focus border) and parks the calendar trigger in its
816        // trailing slot — flush against the field's right edge with
817        // no manual divider, matching Int UI's embedded IconButton convention.
818        let pattern_for_filter = pattern_rc.clone();
819        let mask_string = mask_for_pattern(&pattern_rc);
820        let mut text_input = TextInput::new(self.text_signal.clone())
821            .placeholder(placeholder.clone())
822            // An assistive-technology `SetValue` on the inner text node is a
823            // finished edit, not a keystroke: push the string the technology
824            // set and run the same commit `Enter` runs, or the typed value
825            // stays stale behind a display nothing ever parses.
826            .on_access_set_value({
827                let text_signal = self.text_signal.clone();
828                let commit = commit.clone();
829                // The commit's verdict is the technology's answer: an
830                // unparseable string leaves the value where it was, and
831                // saying `Handled` there would report a write that never
832                // landed.
833                move |text: &str, ctx: &mut EventContext| {
834                    text_signal.set(text.to_string());
835                    commit(ctx)
836                }
837            })
838            .enabled(enabled)
839            .read_only(read_only)
840            .input_mask(mask_string)
841            .validator({
842                let v = validator.clone();
843                move |s| (v)(s)
844            })
845            .char_filter(move |c: char| {
846                if c.is_ascii_digit() || c == '-' || c == ' ' {
847                    return true;
848                }
849                for tok in &pattern_for_filter.tokens {
850                    if let PatternToken::Literal(s) = tok
851                        && s.chars().any(|x| x == c)
852                    {
853                        return true;
854                    }
855                }
856                false
857            })
858            .on_submit_fn({
859                let commit = commit.clone();
860                move |ctx_evt| {
861                    commit(ctx_evt);
862                }
863            })
864            .on_blur_fn({
865                let commit = commit.clone();
866                move |ctx_evt| {
867                    commit(ctx_evt);
868                }
869            });
870        // The field is where focus lands, so it is the node a screen reader
871        // names: unnamed, Orca said "entry 05/02/2026", the name left on the
872        // `DateInput` around it, which it does not speak. `TextInput::label`
873        // names the field itself, not the composite's container, so no named
874        // middle node appears (audit G9).
875        text_input = text_input.label(
876            self.label
877                .clone()
878                .unwrap_or_else(|| localized(|| resolve_message_widget("date-edit-name", &[]))),
879        );
880        self.field_id = text_input.field_id();
881        if let Some(trigger) = trigger_widget_opt {
882            text_input = text_input.trailing_slot(trigger);
883        }
884
885        // Capture caret signal AND a caret setter BEFORE moving the
886        // composite into the tree. The setter is a no-op until
887        // `build()` populates the slot; segment_step uses it to
888        // restore the caret AFTER rewriting text.
889        let caret_for_step = text_input.caret_position();
890        let caret_setter_for_step = text_input.caret_setter();
891
892        // Mirror the inner field's published feedback into our own
893        // signal so the commit closure (which short-circuits on
894        // Invalid) reads the live state. The TextInput composite also
895        // wires this internally to its ValidationStrip.
896        {
897            let inner_feedback = text_input.validation_feedback_signal();
898            let outer_feedback = self.feedback.clone();
899            ctx.effect(&inner_feedback, move |fb| {
900                if outer_feedback.get() != *fb {
901                    outer_feedback.set(fb.clone());
902                }
903            });
904        }
905
906        // Apply width policy. `Default` adds nothing — the field
907        // reports its natural mask-derived width via TextInputField.
908        // `Fill` wraps in an intrinsic-respecting Expand so the
909        // composite stretches to its parent's offered width while
910        // still reporting the natural width when unconstrained
911        // (matches SpinBox's `.fill_width()` semantics).
912        let body_id = match self.width_policy {
913            WidthPolicy::Default => ctx.add(text_input),
914            WidthPolicy::Fill => {
915                let inner_id = ctx.add(text_input);
916                ctx.add(
917                    crate::primitives::Expand::horizontal()
918                        .respect_intrinsic()
919                        .child(inner_id),
920                )
921            }
922        };
923        // Delegate any final wrapping to the active DateEditStyle.
924        let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
925            &self.style_override,
926            ctx,
927        );
928        let cfg = teksilo_core::styles::DateEditStyleConfig { body: body_id };
929        let root_id = style.make_body(&cfg, ctx);
930        self.root_child_id = Some(root_id);
931
932        // ── Tooltip attachment ─────────────────────────────────
933        // Anchored on the visible trigger root (not the calendar overlay).
934        if let Some(content) = self.composite_tooltip_content.take() {
935            let delay = ctx.theme().motion.tooltip_delay_heavy;
936            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
937        } else if let Some(source) = self.rich_tooltip_source.clone() {
938            let delay = ctx.theme().motion.tooltip_delay;
939            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
940        } else if let Some(text) = self.tooltip_text.clone() {
941            let delay = ctx.theme().motion.tooltip_delay;
942            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
943        }
944
945        // ── Segment-stepping helper — captured by the on_key_preview
946        // self handler below. Reads live caret position, looks up the
947        // segment under the caret, and applies a single field step
948        // (year / month / day).
949        let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = {
950            let pattern_for_step = pattern_rc.clone();
951            let value_for_step = self.value.clone();
952            let text_for_step = self.text_signal.clone();
953            let on_changed_for_step = on_value_changed.clone();
954            let min_for_step = self.min_date;
955            let max_for_step = self.max_date;
956            let caret_for_step = caret_for_step.clone();
957            let caret_setter = caret_setter_for_step.clone();
958            Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
959                let caret = caret_for_step.get();
960                let Some((_, _, kind)) = segment_at_position(&pattern_for_step, caret) else {
961                    return;
962                };
963                let current = value_for_step.get().unwrap_or_else(today_local);
964                let stepped = step_date_field(current, kind, delta);
965                let clamped = clamp_date(stepped, min_for_step, max_for_step);
966                value_for_step.set(Some(clamped));
967                text_for_step.set(format_value(&pattern_for_step, Some(clamped), None));
968                // Restore the caret to where it was — `text_signal.set`
969                // → field text effect → `cursor.insert_text` parked the
970                // caret at the document end. Without this restore the
971                // user has to re-click the segment between every Up/Down.
972                caret_setter(caret);
973                if let Some(cb) = on_changed_for_step.as_ref() {
974                    cb(Some(clamped), ctx_evt);
975                }
976                ctx_evt.request_frame();
977            })
978        };
979
980        // ── Self handlers: focus_within + segment-step keys ────
981        // `on_key_preview` on SELF (DateEdit, an actual ancestor of
982        // the inner field) claims ArrowUp/ArrowDown/PageUp/PageDown
983        // BEFORE the focused field's `on_key` runs. The step targets
984        // the segment under the caret (year/month/day) — Qt-style
985        // segment-stepping. Shift multiplies the unit step by 10 so
986        // power users can sweep faster (e.g. ±10 years on the year
987        // segment).
988        let step_for_key = segment_step.clone();
989        let toggle_for_key = toggle_calendar.clone();
990        let popover_open_for_key = self.popover_open.clone();
991        let handlers = HandlerSet::new()
992            .focus_within(self.focused.clone())
993            .on_key_preview(move |event, ctx_evt| {
994                if !enabled || read_only {
995                    return EventResponse::Ignored;
996                }
997                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
998                    return EventResponse::Ignored;
999                };
1000
1001                // `Alt+ArrowDown` opens the calendar, `Alt+ArrowUp` closes it
1002                // and `F4` toggles — the Win32 `DateTimePicker` chords, and
1003                // what this module's documentation has promised since it was
1004                // written. They must be claimed *before* the segment-step match
1005                // below, which reads only `Shift`, so `Alt+ArrowDown` used to
1006                // step the date back one day instead of opening anything.
1007                //
1008                // `Alt+ArrowUp` only reaches here while focus is still in the
1009                // field: the calendar is added detached, so it is not an arena
1010                // child of this widget and a key pressed inside it never
1011                // previews through. Escape remains the close from in there.
1012                // The platform drop-down chords, from the one table `ComboBox`
1013                // and `PopoverWidget` read.
1014                if let Some(toggle) = toggle_for_key.as_ref() {
1015                    match range_nav::disclosure_chord(*key, *modifiers) {
1016                        Some(range_nav::DisclosureChord::Open) => {
1017                            if !popover_open_for_key.get() {
1018                                toggle(ctx_evt);
1019                            }
1020                            return EventResponse::Handled;
1021                        }
1022                        Some(range_nav::DisclosureChord::Close) => {
1023                            if popover_open_for_key.get() {
1024                                toggle(ctx_evt);
1025                                return EventResponse::Handled;
1026                            }
1027                            return EventResponse::Ignored;
1028                        }
1029                        Some(range_nav::DisclosureChord::Toggle) => {
1030                            toggle(ctx_evt);
1031                            return EventResponse::Handled;
1032                        }
1033                        None => {}
1034                    }
1035                }
1036
1037                // Any other accelerator-modified chord is not ours. The segment
1038                // stepper reads `Shift` alone, so `Ctrl+ArrowUp` used to step
1039                // the date and swallow the chord on the way.
1040                if range_nav::is_accelerator_chord(*modifiers) {
1041                    return EventResponse::Ignored;
1042                }
1043
1044                let mult = if modifiers.shift() { 10 } else { 1 };
1045                let delta = match key {
1046                    Key::ArrowUp => mult,
1047                    Key::ArrowDown => -mult,
1048                    Key::PageUp => 10 * mult,
1049                    Key::PageDown => -10 * mult,
1050                    _ => return EventResponse::Ignored,
1051                };
1052                step_for_key(delta, ctx_evt);
1053                EventResponse::Handled
1054            });
1055        ctx.apply_self_handlers(handlers);
1056
1057        // Bind reactive sources at AccessibilityOnly so the wrapper's
1058        // AT node refreshes its `value` and `set_expanded` whenever
1059        // the underlying signals change. Without these, the
1060        // wrapper's accessibility() never re-runs after a value
1061        // change and AT users hear stale data.
1062        let self_id = ctx.self_id();
1063        let registry = ctx.binding_registry();
1064        self.value.bind_to(
1065            self_id,
1066            registry,
1067            teksilo_core::binding::BindingLevel::AccessibilityOnly,
1068        );
1069        self.popover_open.bind_to(
1070            self_id,
1071            registry,
1072            teksilo_core::binding::BindingLevel::AccessibilityOnly,
1073        );
1074
1075        vec![root_id]
1076    }
1077
1078    fn layout_response(
1079        &self,
1080        proposal: SizeProposal,
1081        ctx: &LayoutContext,
1082    ) -> teksilo_core::widget::LayoutResponse {
1083        // Forward the full LayoutResponse — including flex — from the
1084        // child. When `WidthPolicy::Fill` is active, the inner Expand
1085        // wrapper reports flex=1; without forwarding it here, parent
1086        // HStacks see flex=0 and the field never grows.
1087        match self.root_child_id {
1088            Some(id) => ctx
1089                .child_layout_response(id, proposal)
1090                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
1091            None => proposal.resolve(0.0, 0.0).into(),
1092        }
1093    }
1094
1095    fn place_children(
1096        &self,
1097        bounds: Rect,
1098        _proposal: SizeProposal,
1099        children: &mut [WidgetPlacement],
1100        _ctx: &LayoutContext,
1101    ) {
1102        for child in children.iter_mut() {
1103            child.origin = bounds.origin();
1104            child.size = bounds.size();
1105        }
1106    }
1107
1108    fn children(&self) -> Vec<WidgetId> {
1109        self.root_child_id.into_iter().collect()
1110    }
1111
1112    /// The field stands for the date input: what an application attaches to
1113    /// the `DateEdit`'s id (`access_label`, `access_described_by`, a
1114    /// relation) lands on the node that holds focus.
1115    fn accessibility_proxy(&self) -> Option<WidgetId> {
1116        self.field_id.get()
1117    }
1118
1119    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1120        builder.set_role(Role::DateInput);
1121        if let Some(ref label) = self.label {
1122            builder.set_name(label.resolve_now());
1123        } else {
1124            builder.set_name(resolve_message_widget("date-edit-name", &[]));
1125        }
1126        match self.value.get() {
1127            Some(d) => {
1128                builder.set_value(full_date(d, &self.lang));
1129            }
1130            None => {
1131                if !self.placeholder.resolve_now().is_empty() {
1132                    builder.set_placeholder(self.placeholder.resolve_now());
1133                } else {
1134                    builder.set_placeholder(resolve_message_widget("date-edit-placeholder", &[]));
1135                }
1136            }
1137        }
1138        // Framework a11y walker sets `set_disabled` from arena state.
1139        if self.read_only {
1140            builder.set_read_only();
1141        }
1142        builder.add_action(Action::Focus);
1143        // SetValue isn't advertised on this DateInput node because the inner
1144        // TextInputField (Role::TextInput) handles text entry via its own
1145        // TextInputField semantics; routing through both nodes would
1146        // double-process AT requests. The intermediate TextInput
1147        // GenericContainer carries nothing (its label names the field, not
1148        // itself) and is dropped by the presentational-node collapse, so the
1149        // AT tree is exactly DateInput -> TextInput(editable) with its
1150        // character runs.
1151        builder.set_has_popup(HasPopup::Grid);
1152        builder.set_expanded(self.popover_open.get());
1153        // Wire popup-controlled relationship when the calendar
1154        // exists. Pointing only when open caused stale ids on the
1155        // first frame after open; safe to point when closed too —
1156        // the calendar widget remains in the arena (dormant) and
1157        // its NodeId is valid.
1158        if let Some(cal_id) = self.calendar_id {
1159            builder.push_controlled(widget_id_to_node_id(cal_id));
1160        }
1161    }
1162}
1163
1164pub(crate) fn calendar_glyph_icon(size: f32) -> IconWidget {
1165    let mut path = Path::new();
1166    let s = size;
1167    // Outer rounded rectangle suggesting a calendar.
1168    let m = s * 0.1;
1169    path.move_to(Point::new(m, s * 0.25));
1170    path.line_to(Point::new(s - m, s * 0.25));
1171    path.line_to(Point::new(s - m, s - m));
1172    path.line_to(Point::new(m, s - m));
1173    path.close();
1174    // Top binding stripe.
1175    path.move_to(Point::new(m, s * 0.25));
1176    path.line_to(Point::new(s - m, s * 0.25));
1177    path.line_to(Point::new(s - m, s * 0.4));
1178    path.line_to(Point::new(m, s * 0.4));
1179    path.close();
1180    // Two binding rings.
1181    let ring_y_top = s * 0.1;
1182    let ring_y_bot = s * 0.3;
1183    let ring_w = s * 0.06;
1184    let ring1_x = s * 0.25;
1185    let ring2_x = s * 0.65;
1186    path.move_to(Point::new(ring1_x, ring_y_top));
1187    path.line_to(Point::new(ring1_x + ring_w, ring_y_top));
1188    path.line_to(Point::new(ring1_x + ring_w, ring_y_bot));
1189    path.line_to(Point::new(ring1_x, ring_y_bot));
1190    path.close();
1191    path.move_to(Point::new(ring2_x, ring_y_top));
1192    path.line_to(Point::new(ring2_x + ring_w, ring_y_top));
1193    path.line_to(Point::new(ring2_x + ring_w, ring_y_bot));
1194    path.line_to(Point::new(ring2_x, ring_y_bot));
1195    path.close();
1196    IconWidget::from_path(path, size)
1197}
1198
1199pub(crate) fn clamp_date(d: Date, min: Option<Date>, max: Option<Date>) -> Date {
1200    let d = match min {
1201        Some(min) if d < min => min,
1202        _ => d,
1203    };
1204    match max {
1205        Some(max) if d > max => max,
1206        _ => d,
1207    }
1208}
1209
1210/// Build a date-validator closure suitable for plugging into
1211/// `TextInputField::validator(...)`. Encapsulates the strict-parse →
1212/// clamp-recovery → reject pipeline that `DateEdit` itself uses,
1213/// so other widgets composing a `TextInputField` over a date pattern
1214/// (e.g. `DateRangeEdit`'s start / end halves) can reuse the same
1215/// validation behaviour without duplicating ~50 lines.
1216///
1217/// `pattern` and `behavior` are captured by value; `min`/`max` clamp
1218/// the parsed date when present.
1219pub(crate) fn build_date_validator(
1220    pattern: Rc<ParsedPattern>,
1221    min: Option<Date>,
1222    max: Option<Date>,
1223    behavior: ValidationBehavior,
1224) -> crate::primitives::text_input_field::ValidatorFn {
1225    Rc::new(move |raw: &str| -> ValidationOutcome {
1226        let trimmed = raw.trim();
1227        if trimmed.is_empty() {
1228            return ValidationOutcome::Valid;
1229        }
1230        if let Some(ParsedValue::Date(d)) = parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
1231            let clamped = clamp_date(d, min, max);
1232            let formatted = format_value(&pattern, Some(clamped), None);
1233            if formatted == trimmed && clamped == d {
1234                return ValidationOutcome::Valid;
1235            }
1236            return ValidationOutcome::Corrected {
1237                corrected: formatted.clone(),
1238                message: localized(move || {
1239                    resolve_message_widget(
1240                        "validation-corrected-to",
1241                        &[("value", formatted.clone().into())],
1242                    )
1243                }),
1244            };
1245        }
1246        if behavior == ValidationBehavior::AutoCorrect
1247            && let Some((corrected, msg)) = try_clamp_recovery(&pattern, trimmed, min, max)
1248        {
1249            return ValidationOutcome::Corrected {
1250                corrected,
1251                message: msg,
1252            };
1253        }
1254        ValidationOutcome::Invalid {
1255            message: localized(move || {
1256                resolve_message_widget("date-edit-validation-not-a-date", &[])
1257            }),
1258        }
1259    })
1260}
1261
1262/// AutoCorrect recovery: extract per-segment integer values from the
1263/// raw input by walking the pattern, clamp each to its valid range
1264/// (year as-is within jiff's bounds; month → 1..=12; day → 1..=
1265/// days_in_month for the resulting year/month), and re-construct.
1266///
1267/// Returns `Some((formatted, message))` on successful recovery,
1268/// `None` if the input is too malformed (e.g., contains non-digits at
1269/// digit positions or doesn't have enough segments).
1270///
1271/// Examples (pattern `%d/%m/%Y`):
1272/// - `"12/50/2026"` → `Some(("12/12/2026", "Auto-corrected: month 50 → 12"))`
1273///   (month clamped to its max 12)
1274/// - `"31/2/2024"` → `Some(("29/02/2024", "Auto-corrected: day 31 → 29 (last day of February)"))`
1275///   (day clamped to month length)
1276/// - `"abc"` → `None`
1277pub(crate) fn try_clamp_recovery(
1278    pattern: &ParsedPattern,
1279    raw: &str,
1280    min: Option<Date>,
1281    max: Option<Date>,
1282) -> Option<(String, LocalizedString)> {
1283    // Walk the pattern; for each digit segment, take whatever digit
1284    // run starts at the current cursor position. For literal tokens,
1285    // optionally consume the literal (lenient — same logic as
1286    // parse_value's literal handling).
1287    let mut cursor = raw;
1288    let mut year: Option<i16> = None;
1289    let mut month: Option<i8> = None;
1290    let mut day: Option<i8> = None;
1291    let mut clamp_notes: Vec<LocalizedString> = Vec::new();
1292
1293    for token in &pattern.tokens {
1294        if cursor.is_empty() {
1295            break;
1296        }
1297        match token {
1298            PatternToken::Literal(lit) => {
1299                if let Some(rest) = cursor.strip_prefix(lit.as_str()) {
1300                    cursor = rest;
1301                } else if lit.starts_with(cursor) {
1302                    cursor = "";
1303                } else {
1304                    // Literal doesn't match → can't recover, give up.
1305                    return None;
1306                }
1307            }
1308            PatternToken::Segment(kind) => {
1309                let max_d = kind.max_digits();
1310                if max_d == 0 {
1311                    continue; // Period segments not handled here
1312                }
1313                let mut end = 0usize;
1314                for (i, ch) in cursor.char_indices() {
1315                    if ch.is_ascii_digit() && end < max_d {
1316                        end = i + ch.len_utf8();
1317                    } else {
1318                        break;
1319                    }
1320                }
1321                if end == 0 {
1322                    // No digits where we expected them; bail.
1323                    return None;
1324                }
1325                let digits = &cursor[..end];
1326                cursor = &cursor[end..];
1327                let raw_v: i32 = digits.parse().ok()?;
1328                let (lo, hi) = kind.value_range().unwrap_or((i32::MIN, i32::MAX));
1329                let clamped = raw_v.clamp(lo, hi);
1330                if clamped != raw_v {
1331                    let segment_key = match kind {
1332                        SegmentKind::Year => "validation-segment-year",
1333                        SegmentKind::Month | SegmentKind::MonthShort => "validation-segment-month",
1334                        SegmentKind::Day | SegmentKind::DayShort => "validation-segment-day",
1335                        _ => "validation-segment-value",
1336                    };
1337                    let segment_label = resolve_message_widget(segment_key, &[]);
1338                    clamp_notes.push(localized(move || {
1339                        resolve_message_widget(
1340                            "validation-segment-clamped",
1341                            &[
1342                                ("segment", segment_label.clone().into()),
1343                                ("raw", (raw_v as i64).into()),
1344                                ("clamped", (clamped as i64).into()),
1345                            ],
1346                        )
1347                    }));
1348                }
1349                match kind {
1350                    SegmentKind::Year => year = Some(clamped as i16),
1351                    SegmentKind::Month | SegmentKind::MonthShort => month = Some(clamped as i8),
1352                    SegmentKind::Day | SegmentKind::DayShort => day = Some(clamped as i8),
1353                    _ => {}
1354                }
1355            }
1356        }
1357    }
1358
1359    let y = year?;
1360    let m = month.unwrap_or(1);
1361    // Day: clamp to days-in-month for the resolved (y, m). This
1362    // catches "31 February" → "28/29 February" (depends on leap).
1363    let last_day = YearMonth::new(y, m).last_day().day();
1364    let raw_day = day.unwrap_or(1);
1365    let d = raw_day.min(last_day).max(1);
1366    if d != raw_day {
1367        clamp_notes.push(localized(move || {
1368            resolve_message_widget(
1369                "validation-day-clamped-to-month",
1370                &[
1371                    ("raw", (raw_day as i64).into()),
1372                    ("clamped", (d as i64).into()),
1373                ],
1374            )
1375        }));
1376    }
1377
1378    let date = Date::new(y, m, d).ok()?;
1379    let final_date = clamp_date(date, min, max);
1380    if final_date != date {
1381        clamp_notes.push(localized(move || {
1382            resolve_message_widget("validation-clamped-to-range", &[])
1383        }));
1384    }
1385
1386    let formatted = format_value(pattern, Some(final_date), None);
1387    let formatted_for_msg = formatted.clone();
1388    let message = if clamp_notes.is_empty() {
1389        localized(move || {
1390            resolve_message_widget(
1391                "validation-corrected-to",
1392                &[("value", formatted_for_msg.clone().into())],
1393            )
1394        })
1395    } else {
1396        // For the notes case, we need to resolve all notes and join them.
1397        // We'll resolve them at display time.
1398        localized(move || {
1399            let notes_str: String = clamp_notes
1400                .iter()
1401                .map(|n| n.resolve_now())
1402                .collect::<Vec<_>>()
1403                .join(", ");
1404            resolve_message_widget(
1405                "validation-corrected-with-notes",
1406                &[("notes", notes_str.into())],
1407            )
1408        })
1409    };
1410    Some((formatted, message))
1411}