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}