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}