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