Skip to main content

teksilo_widgets/primitives/text_input_field/
validator.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Validation pipeline shared by `TextInputField` and the composites
5//! that render its feedback (`TextInput`, `DateEdit`, `TimeEdit`,
6//! `DateTimeEdit`).
7//!
8//! # Pipeline shape
9//!
10//! 1. The user commits the field (Enter, Tab-out, focus loss).
11//! 2. The field's [`ValidatorFn`] runs on the current text.
12//! 3. The validator returns one of three outcomes:
13//!    - [`ValidationOutcome::Valid`] — keep the text as-is.
14//!    - [`ValidationOutcome::Corrected`] — replace the text with a
15//!      normalized form, surface a polite announcement.
16//!    - [`ValidationOutcome::Invalid`] — revert to the pre-edit value,
17//!      surface an assertive error.
18//! 4. The field writes the resolved [`ValidationFeedback`] to its
19//!    published signal so composites can render the inline strip.
20//!
21//! # Why `ValidationOutcome` and `ValidationFeedback` are separate
22//!
23//! The outcome is what the validator *returns* (input → categorized
24//! result). The feedback is what observers *see* (categorized result
25//! plus a `since` timestamp for time-based UI decay). Splitting the
26//! types keeps validators stateless and lets the field own the
27//! lifecycle (decay, reset on re-edit) without leaking timing concerns
28//! into validator code.
29
30use std::rc::Rc;
31use std::time::Instant;
32use teksilo_i18n::LocalizedString;
33
34/// What a validator returns for a given commit attempt.
35#[derive(Debug, Clone)]
36pub enum ValidationOutcome {
37    /// Input is valid as typed. The field commits unchanged and the
38    /// feedback signal flips to [`ValidationFeedback::Valid`].
39    Valid,
40    /// Input was accepted after normalization. The field replaces its
41    /// text with `corrected`, the bound `Signal<String>` observes the
42    /// new value, and the feedback signal carries `message` for
43    /// composites to surface as a polite announcement.
44    ///
45    /// Use for clamping, completion, and reformat. Examples:
46    /// `"12/50/2026"` → `Corrected { corrected: "12/31/2026", … }`
47    /// for "day clamped to month length"; `"2026"` →
48    /// `Corrected { corrected: "2026-01-01", … }` for "year-only
49    /// completed to start of year".
50    Corrected {
51        corrected: String,
52        message: LocalizedString,
53    },
54    /// Input is rejected. The field reverts its text to the pre-edit
55    /// value and the feedback signal carries `message` for composites
56    /// to surface as an assertive error.
57    Invalid { message: LocalizedString },
58}
59
60/// What composites render. Distinct from [`ValidationOutcome`]: the
61/// outcome is the validator's return value (no time concept); the
62/// feedback adds a `since` instant so the visual layer can decay an
63/// auto-correction announcement after a window without re-running the
64/// validator.
65#[derive(Debug, Clone, Default)]
66pub enum ValidationFeedback {
67    /// No commit has happened yet, or the user is editing again after
68    /// a previous outcome (typing always clears prior feedback).
69    #[default]
70    Pristine,
71    /// Last commit returned [`ValidationOutcome::Valid`]. Composites
72    /// typically render this identically to `Pristine` — the
73    /// distinction matters for tests and for callers that want to
74    /// signal "yes, it's confirmed valid" with a checkmark.
75    Valid,
76    /// Last commit returned [`ValidationOutcome::Corrected`]. `since`
77    /// is the wall-clock instant the correction was applied; composites
78    /// use it to decay the visual after `corrected_pulse_duration_ms`
79    /// from the theme.
80    Corrected {
81        message: LocalizedString,
82        since: Instant,
83    },
84    /// Last commit returned [`ValidationOutcome::Invalid`]. Persists
85    /// until the user edits again or an external `Pristine` reset.
86    Invalid { message: LocalizedString },
87}
88
89// Manual `PartialEq` (the derive is impossible — `LocalizedString` is not
90// `PartialEq` because its resolver is a closure). Two outcomes/feedbacks are
91// equal when they're the same variant with the same data and the same
92// *resolved* message text. This matches the prior derived semantics for the
93// `corrected` / `since` fields while comparing messages by what the user
94// actually sees.
95impl PartialEq for ValidationOutcome {
96    fn eq(&self, other: &Self) -> bool {
97        match (self, other) {
98            (Self::Valid, Self::Valid) => true,
99            (
100                Self::Corrected {
101                    corrected: c1,
102                    message: m1,
103                },
104                Self::Corrected {
105                    corrected: c2,
106                    message: m2,
107                },
108            ) => c1 == c2 && m1.resolve_now() == m2.resolve_now(),
109            (Self::Invalid { message: m1 }, Self::Invalid { message: m2 }) => {
110                m1.resolve_now() == m2.resolve_now()
111            }
112            _ => false,
113        }
114    }
115}
116
117impl PartialEq for ValidationFeedback {
118    fn eq(&self, other: &Self) -> bool {
119        match (self, other) {
120            (Self::Pristine, Self::Pristine) | (Self::Valid, Self::Valid) => true,
121            (
122                Self::Corrected {
123                    message: m1,
124                    since: s1,
125                },
126                Self::Corrected {
127                    message: m2,
128                    since: s2,
129                },
130            ) => s1 == s2 && m1.resolve_now() == m2.resolve_now(),
131            (Self::Invalid { message: m1 }, Self::Invalid { message: m2 }) => {
132                m1.resolve_now() == m2.resolve_now()
133            }
134            _ => false,
135        }
136    }
137}
138
139impl ValidationFeedback {
140    /// Convenience: is this state currently signalling an error?
141    pub fn is_invalid(&self) -> bool {
142        matches!(self, Self::Invalid { .. })
143    }
144
145    /// Convenience: was the last commit auto-corrected?
146    pub fn is_corrected(&self) -> bool {
147        matches!(self, Self::Corrected { .. })
148    }
149
150    /// Human-readable message, if any.
151    pub fn message(&self) -> Option<String> {
152        match self {
153            Self::Corrected { message, .. } | Self::Invalid { message } => {
154                Some(message.resolve_now())
155            }
156            _ => None,
157        }
158    }
159}
160
161/// The closure signature the field calls on every commit. Stateless —
162/// the field owns the pre-edit text and reverts on `Invalid` itself.
163pub type ValidatorFn = Rc<dyn Fn(&str) -> ValidationOutcome>;
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use teksilo_i18n::lit;
169
170    #[test]
171    fn feedback_is_invalid_helper() {
172        assert!(!ValidationFeedback::Pristine.is_invalid());
173        assert!(!ValidationFeedback::Valid.is_invalid());
174        assert!(
175            !ValidationFeedback::Corrected {
176                message: lit!("x"),
177                since: Instant::now(),
178            }
179            .is_invalid()
180        );
181        assert!(ValidationFeedback::Invalid { message: lit!("x") }.is_invalid());
182    }
183
184    #[test]
185    fn feedback_message_accessor() {
186        assert_eq!(ValidationFeedback::Pristine.message(), None);
187        assert_eq!(ValidationFeedback::Valid.message(), None);
188        assert_eq!(
189            ValidationFeedback::Invalid {
190                message: lit!("bad")
191            }
192            .message(),
193            Some("bad".to_string())
194        );
195        assert_eq!(
196            ValidationFeedback::Corrected {
197                message: lit!("fixed"),
198                since: Instant::now(),
199            }
200            .message(),
201            Some("fixed".to_string())
202        );
203    }
204}