Skip to main content

frus_widgets/
form.rs

1//! Form validation — **pure**, application-side, in the Elm spirit.
2//!
3//! Two bricks that combine:
4//!
5//! - [`crate::form::Rule`]: one field's validation rule (`&str -> Option<String>`,
6//!   `Some(message)` when invalid), with ready-made constructors (`required`,
7//!   `min_len`, `email`…) and a [`crate::form::Rule::all`] combinator (the first failing rule
8//!   wins).
9//! - [`crate::form::Form`]: validates a **set** of fields declared in order, then answers three
10//!   questions — is everything valid? what is field `key`'s error? which is the
11//!   **first** failing field, the one to focus?
12//!
13//! Nothing here draws: the application calls [`crate::form::Form::error`] to feed a
14//! [`crate::TextField`]'s `error(...)`, and [`crate::form::Form::first_invalid`] to target the
15//! field to bring out. Validity stays a **pure function of the state**.
16//!
17//! ```
18//! use frus_widgets::form::{Form, Rule};
19//!
20//! let report = Form::new()
21//!     .field("email", "ada@", Rule::all([
22//!         Rule::required("Required"),
23//!         Rule::email("Enter a valid email address"),
24//!     ]))
25//!     .field("password", "secret", Rule::min_len(8, "At least 8 characters"));
26//!
27//! assert!(!report.is_valid());
28//! assert_eq!(report.error("email"), Some("Enter a valid email address"));
29//! assert_eq!(report.first_invalid(), Some("email"));
30//! ```
31
32use frus_core::{
33    Color, Insets, Point, Rect, ResolvedTextStyle, Role, Scene, SemanticsProperties, TextStyle,
34};
35use frus_layout::{Dimension, FlexDirection, Style};
36
37use crate::interaction::Status;
38use crate::text::Text;
39use crate::theme::Theme;
40use crate::widget::Widget;
41
42/// A check over one field's value: `None` when it passes, the message when it does not.
43type Check = Box<dyn Fn(&str) -> Option<String>>;
44
45/// One field's validation rule: returns `Some(message)` if the value is invalid,
46/// `None` if it passes.
47pub struct Rule(Check);
48
49impl Rule {
50    /// An arbitrary rule from a closure.
51    pub fn new(check: impl Fn(&str) -> Option<String> + 'static) -> Self {
52        Self(Box::new(check))
53    }
54
55    /// Applies the rule to `value`.
56    pub fn check(&self, value: &str) -> Option<String> {
57        (self.0)(value)
58    }
59
60    /// Non-empty (leading and trailing whitespace ignored).
61    pub fn required(message: impl Into<String>) -> Self {
62        let message = message.into();
63        Self::new(move |v| (v.trim().is_empty()).then(|| message.clone()))
64    }
65
66    /// At least `n` characters (counted as `char`s, whitespace included).
67    pub fn min_len(n: usize, message: impl Into<String>) -> Self {
68        let message = message.into();
69        Self::new(move |v| (v.chars().count() < n).then(|| message.clone()))
70    }
71
72    /// At most `n` characters.
73    pub fn max_len(n: usize, message: impl Into<String>) -> Self {
74        let message = message.into();
75        Self::new(move |v| (v.chars().count() > n).then(|| message.clone()))
76    }
77
78    /// A plausible e-mail shape: `local@domain`, a non-empty local part, and a domain
79    /// containing a dot that is not flush against the edges (a heuristic, not the RFC).
80    pub fn email(message: impl Into<String>) -> Self {
81        let message = message.into();
82        Self::new(move |v| (!is_email(v)).then(|| message.clone()))
83    }
84
85    /// Combines rules: the **first** to fail, in order, wins.
86    pub fn all(rules: impl IntoIterator<Item = Rule>) -> Self {
87        let rules: Vec<Rule> = rules.into_iter().collect();
88        Self::new(move |v| rules.iter().find_map(|r| r.check(v)))
89    }
90}
91
92/// Heuristique d'e-mail (voir [`Rule::email`]).
93fn is_email(v: &str) -> bool {
94    let v = v.trim();
95    let Some((local, domain)) = v.split_once('@') else {
96        return false;
97    };
98    // A single at-sign, and a non-empty local part.
99    if local.is_empty() || domain.contains('@') {
100        return false;
101    }
102    // The domain: at least one dot, and no empty label (no `.x`, no `x.`, no `a..b`).
103    domain.contains('.') && domain.split('.').all(|label| !label.is_empty())
104}
105
106/// The validation report of a set of fields, in declaration order. Built by chaining
107/// [`Form::field`], then consulted. The **values** are recorded so that **cross-field
108/// validation** is possible: one field compared with another.
109#[derive(Default)]
110pub struct Form {
111    fields: Vec<(&'static str, String, Option<String>)>,
112}
113
114impl Form {
115    /// An empty form (everything is valid while no field is declared).
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Validates `value` with `rule` and records the result under `key`.
121    pub fn field(mut self, key: &'static str, value: &str, rule: Rule) -> Self {
122        let error = rule.check(value);
123        self.fields.push((key, value.to_string(), error));
124        self
125    }
126
127    /// Validates a field with a **cross-field** function receiving its value **and** the
128    /// partial form (the fields **already declared**): `check(value, form)` can consult
129    /// [`form.value(other)`](Self::value) — a confirmed password, consistent dates, and
130    /// so on. So declare the referenced field **first**.
131    pub fn field_with(
132        mut self,
133        key: &'static str,
134        value: &str,
135        check: impl Fn(&str, &Form) -> Option<String>,
136    ) -> Self {
137        let error = check(value, &self);
138        self.fields.push((key, value.to_string(), error));
139        self
140    }
141
142    /// A cross-field convenience: `value` must **equal** field `other`'s value (already
143    /// declared) — the "confirm the password" case. Otherwise `message`.
144    pub fn matches(
145        self,
146        key: &'static str,
147        value: &str,
148        other: &'static str,
149        message: impl Into<String>,
150    ) -> Self {
151        let message = message.into();
152        self.field_with(key, value, move |v, form| {
153            (form.value(other) != Some(v)).then(|| message.clone())
154        })
155    }
156
157    /// Field `key`'s recorded value, for cross-field validation.
158    pub fn value(&self, key: &str) -> Option<&str> {
159        self.fields
160            .iter()
161            .find(|(k, _, _)| *k == key)
162            .map(|(_, v, _)| v.as_str())
163    }
164
165    /// `true` if no field is in error.
166    pub fn is_valid(&self) -> bool {
167        self.fields.iter().all(|(_, _, e)| e.is_none())
168    }
169
170    /// Field `key`'s error message, if it has one.
171    pub fn error(&self, key: &str) -> Option<&str> {
172        self.fields
173            .iter()
174            .find(|(k, _, _)| *k == key)
175            .and_then(|(_, _, e)| e.as_deref())
176    }
177
178    /// Every error message as `(key, message)`, in declaration order — for a **summary**
179    /// at the head of the form (see [`ErrorSummary`]).
180    pub fn errors(&self) -> Vec<(&'static str, &str)> {
181        self.fields
182            .iter()
183            .filter_map(|(k, _, e)| e.as_deref().map(|m| (*k, m)))
184            .collect()
185    }
186
187    /// The key of the **first** field in error (declaration order) — typically the
188    /// one to focus or bring out on submission.
189    pub fn first_invalid(&self) -> Option<&'static str> {
190        self.fields
191            .iter()
192            .find(|(_, _, e)| e.is_some())
193            .map(|(k, _, _)| *k)
194    }
195}
196
197/// The error summary's inner padding.
198const SUMMARY_PAD: f32 = 12.0;
199
200/// An **error summary**: an "error"-tinted card listing the messages (a "Please fix N
201/// error(s)" title, then one bullet per message), to place at the head of a form after
202/// an invalid submission. Built from [`Form::errors`]. Empty when there is no message,
203/// and then it should not be displayed — see [`is_empty`](Self::is_empty).
204///
205/// The bullets are **inert** with [`new`](Self::new); with [`links`](Self::links) each
206/// one carries an application message emitted on click — typically to **focus** the
207/// offending field (via `Command::focus`), the summary then acting as a table of
208/// contents of the errors.
209pub struct ErrorSummary<Msg = crate::callback::Callback> {
210    empty: bool,
211    items: Vec<(String, Option<Msg>)>,
212    title_text_style: Option<TextStyle>,
213    bullet_text_style: Option<TextStyle>,
214    children: Vec<Box<dyn Widget<Msg>>>,
215}
216
217impl<Msg: Clone + 'static> ErrorSummary<Msg> {
218    /// Builds the summary from the messages (the order is preserved). **Inert** bullets.
219    pub fn new(messages: impl IntoIterator<Item = impl Into<String>>) -> Self {
220        Self::assemble(messages.into_iter().map(|m| (m.into(), None)).collect())
221    }
222
223    /// A **clickable** summary: each `(message, msg)` becomes a bullet that emits `msg` on
224    /// click, to focus the matching field. The order is preserved.
225    pub fn links(items: impl IntoIterator<Item = (impl Into<String>, Msg)>) -> Self {
226        Self::assemble(
227            items
228                .into_iter()
229                .map(|(m, msg)| (m.into(), Some(msg)))
230                .collect(),
231        )
232    }
233
234    /// Assembles the title + the bullets (clickable when a message is supplied).
235    fn assemble(items: Vec<(String, Option<Msg>)>) -> Self {
236        let mut summary = Self {
237            empty: items.is_empty(),
238            items,
239            title_text_style: None,
240            bullet_text_style: None,
241            children: Vec::new(),
242        };
243        summary.rebuild();
244        summary
245    }
246
247    /// The heading's type, over the theme's and the framework's.
248    #[must_use]
249    pub fn title_text_style(mut self, style: TextStyle) -> Self {
250        self.title_text_style = Some(style);
251        self.rebuild();
252        self
253    }
254
255    /// The bullets' type, over the theme's and the framework's.
256    #[must_use]
257    pub fn bullet_text_style(mut self, style: TextStyle) -> Self {
258        self.bullet_text_style = Some(style);
259        self.rebuild();
260        self
261    }
262
263    /// Carries the current styles into the heading and every bullet, so that the builders
264    /// are order-independent.
265    fn rebuild(&mut self) {
266        let title = match self.items.len() {
267            1 => "Please fix 1 error".to_string(),
268            n => format!("Please fix {n} errors"),
269        };
270        let mut children: Vec<Box<dyn Widget<Msg>>> = Vec::with_capacity(self.items.len() + 1);
271        children.push(Box::new(Text::styled(
272            title,
273            summary_title_style(self.title_text_style, None),
274        )));
275        for (message, msg) in &self.items {
276            children.push(Box::new(Bullet {
277                label: format!("• {message}"),
278                message: msg.clone(),
279                text_style: self.bullet_text_style,
280            }));
281        }
282        self.children = children;
283    }
284
285    /// `true` when there is no message — the caller can then display nothing.
286    pub fn is_empty(&self) -> bool {
287        self.empty
288    }
289}
290
291/// A summary bullet's style: what the caller said, else what the theme says, else
292/// `bodySmall`.
293///
294/// The reference has no error summary, so this one is argued rather than read: a bullet
295/// **restates** an error already shown under its field, and the reference sets that helper
296/// line in `bodySmall`.
297///
298/// **Resolved once**, so that the number the box is measured with is the number the glyphs
299/// are drawn at.
300fn bullet_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
301    over.or(theme.and_then(|t| t.widgets.form.bullet_text_style))
302        .unwrap_or_else(|| crate::theme::type_scale(theme).body_small)
303        .resolved()
304}
305
306/// The summary's heading over the bullets: `titleSmall`, the step a heading over a short
307/// list takes.
308///
309/// Left as a [`TextStyle`] rather than resolved, because the heading is a [`Text`] child
310/// and a `Text` resolves its own.
311fn summary_title_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
312    over.or(theme.and_then(|t| t.widgets.form.summary_title_text_style))
313        .unwrap_or_else(|| crate::theme::type_scale(theme).title_small)
314}
315
316/// One summary line ("• message"). **Clickable** when it carries a message (it then
317/// focuses the offending field); plain text otherwise.
318struct Bullet<Msg> {
319    label: String,
320    message: Option<Msg>,
321    text_style: Option<TextStyle>,
322}
323
324impl<Msg> Bullet<Msg> {
325    fn sizing(&self, theme: Option<&Theme>) -> Style {
326        let measured =
327            frus_text::measure_resolved(&self.label, &bullet_style(self.text_style, theme));
328        Style {
329            height: Dimension::Length((measured.height + 4.0).ceil()),
330            padding: Insets::new(2.0, 6.0, 2.0, 6.0),
331            ..Default::default()
332        }
333    }
334}
335
336impl<Msg: Clone> Widget<Msg> for Bullet<Msg> {
337    /// It asks to **fill the width it is offered** rather than declaring one — see
338    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
339    /// width, which a parent that shrink-wraps does not have yet.
340    fn style(&self) -> Style {
341        self.sizing(None)
342    }
343
344    fn style_themed(&self, theme: &Theme) -> Style {
345        self.sizing(Some(theme))
346    }
347
348    /// The width it was **offered**, not the width its parent came out at.
349    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
350        crate::widget::FillAxes::WIDTH
351    }
352
353    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
354        &[]
355    }
356
357    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
358        let o = status.opacity;
359        // A discreet highlight on hover or focus when the bullet is clickable.
360        let highlight = status.hover_progress.max(status.focus_progress);
361        if self.message.is_some() && highlight > 0.0 {
362            let tint = theme.error.fade(0.12 * highlight * o);
363            scene.draw_rect(bounds, tint, theme.radius, 0.0, Color::TRANSPARENT);
364        }
365        scene.text(
366            Point::new(bounds.x + 6.0, bounds.y + 2.0),
367            self.label.clone(),
368            &bullet_style(self.text_style, Some(theme)),
369            theme.on_surface.fade(o),
370        );
371    }
372
373    fn on_click(&self) -> Option<Msg> {
374        self.message.clone()
375    }
376
377    fn focusable(&self) -> bool {
378        self.message.is_some()
379    }
380
381    fn semantics(&self) -> Option<SemanticsProperties> {
382        self.message.as_ref().map(|_| {
383            SemanticsProperties::new(Role::Button)
384                .label(self.label.clone())
385                .clickable()
386        })
387    }
388}
389
390impl<Msg: Clone> Widget<Msg> for ErrorSummary<Msg> {
391    /// It asks to **fill the width it is offered** rather than declaring one — see
392    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
393    /// width, which a parent that shrink-wraps does not have yet.
394    fn style(&self) -> Style {
395        Style {
396            flex_direction: FlexDirection::Column,
397            padding: Insets::new(SUMMARY_PAD, SUMMARY_PAD, SUMMARY_PAD, SUMMARY_PAD),
398            gap: 4.0,
399            ..Default::default()
400        }
401    }
402
403    /// The width it was **offered**, not the width its parent came out at.
404    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
405        crate::widget::FillAxes::WIDTH
406    }
407
408    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
409        &self.children
410    }
411
412    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
413        // The "error"-tinted card (a soft background + a border), under the text lines.
414        let o = status.opacity;
415        let bg = theme.surface.lerp(theme.error, 0.12);
416        scene.draw_rect(bounds, bg.fade(o), theme.radius, 1.0, theme.error.fade(o));
417    }
418
419    fn on_click(&self) -> Option<Msg> {
420        None
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn required_rejects_blank() {
430        let rule = Rule::required("Required");
431        assert_eq!(rule.check("   "), Some("Required".to_string()));
432        assert_eq!(rule.check("a"), None);
433    }
434
435    #[test]
436    fn length_bounds() {
437        assert!(Rule::min_len(3, "short").check("ab").is_some());
438        assert!(Rule::min_len(3, "short").check("abc").is_none());
439        assert!(Rule::max_len(3, "long").check("abcd").is_some());
440    }
441
442    #[test]
443    fn email_heuristic() {
444        let rule = Rule::email("bad");
445        assert!(rule.check("ada@example.com").is_none());
446        assert!(rule.check("ada@mail.example.co").is_none());
447        assert!(rule.check("ada@").is_some());
448        assert!(rule.check("ada").is_some());
449        assert!(rule.check("@example.com").is_some());
450        assert!(rule.check("ada@example").is_some());
451    }
452
453    #[test]
454    fn all_returns_the_first_failure() {
455        let rule = Rule::all([Rule::required("Required"), Rule::email("Invalid email")]);
456        // Empty → the first rule (required) wins.
457        assert_eq!(rule.check(""), Some("Required".to_string()));
458        // Non-empty but not an e-mail → the second.
459        assert_eq!(rule.check("nope"), Some("Invalid email".to_string()));
460        // Valid → no error.
461        assert_eq!(rule.check("ada@example.com"), None);
462    }
463
464    #[test]
465    fn form_reports_validity_errors_and_first_invalid() {
466        let report = Form::new()
467            .field("email", "nope", Rule::email("Invalid email"))
468            .field("password", "short", Rule::min_len(8, "Too short"))
469            .field("name", "Ada", Rule::required("Required"));
470        assert!(!report.is_valid());
471        assert_eq!(report.error("email"), Some("Invalid email"));
472        assert_eq!(report.error("password"), Some("Too short"));
473        assert_eq!(report.error("name"), None, "a valid field → no error");
474        assert_eq!(
475            report.first_invalid(),
476            Some("email"),
477            "the first to fail, in order"
478        );
479    }
480
481    #[test]
482    fn empty_form_is_valid() {
483        let report = Form::new();
484        assert!(report.is_valid());
485        assert_eq!(report.first_invalid(), None);
486    }
487
488    #[test]
489    fn cross_field_confirm_password() {
490        // `confirm` must equal `password`, which is declared before it.
491        let report = |pw: &str, cf: &str| {
492            Form::new()
493                .field("password", pw, Rule::min_len(8, "Too short"))
494                .matches("confirm", cf, "password", "Passwords do not match")
495        };
496        assert_eq!(
497            report("secret12", "secret12").error("confirm"),
498            None,
499            "identiques → OK"
500        );
501        let bad = report("secret12", "secretXX");
502        assert_eq!(bad.error("confirm"), Some("Passwords do not match"));
503        assert_eq!(bad.first_invalid(), Some("confirm"));
504        // `field_with` generalises: access to another value through `form.value`.
505        let dates = Form::new()
506            .field("start", "10", Rule::new(|_| None))
507            .field_with("end", "5", |v, form| {
508                let start: i32 = form.value("start").unwrap_or("0").parse().unwrap_or(0);
509                (v.parse::<i32>().unwrap_or(0) < start).then(|| "End before start".to_string())
510            });
511        assert_eq!(dates.error("end"), Some("End before start"));
512    }
513
514    #[test]
515    fn errors_lists_all_messages_in_order() {
516        let report = Form::new()
517            .field("email", "nope", Rule::email("Invalid email"))
518            .field("name", "Ada", Rule::required("Required"))
519            .field("password", "x", Rule::min_len(8, "Too short"));
520        assert_eq!(
521            report.errors(),
522            vec![("email", "Invalid email"), ("password", "Too short")],
523            "the valid ones are omitted, the order preserved",
524        );
525    }
526
527    #[test]
528    fn error_summary_lists_messages() {
529        use crate::{build_ui, Runtime, Size, Theme};
530        use frus_core::Primitive;
531        let summary = ErrorSummary::<()>::new(["Invalid email", "Too short"]);
532        assert!(!summary.is_empty());
533        let ui = build_ui(
534            &summary,
535            Size::new(300.0, 120.0),
536            &Runtime::default(),
537            &Theme::default(),
538        );
539        let painted = |t: &str| {
540            ui.scene()
541                .primitives()
542                .iter()
543                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
544        };
545        assert!(painted("Please fix 2 errors"), "the summary's title");
546        assert!(
547            painted("• Invalid email") && painted("• Too short"),
548            "one bullet per message"
549        );
550        // Empty → not to be displayed.
551        assert!(ErrorSummary::<()>::new(Vec::<String>::new()).is_empty());
552    }
553
554    #[test]
555    fn error_summary_links_emit_focus_messages() {
556        #[derive(Clone, Debug, PartialEq)]
557        enum Msg {
558            Focus(&'static str),
559        }
560        let summary = ErrorSummary::links([
561            ("Invalid email", Msg::Focus("email")),
562            ("Too short", Msg::Focus("password")),
563        ]);
564        assert!(!summary.is_empty());
565        let kids = Widget::<Msg>::children(&summary);
566        // [0] = the title (inert); [1..] = clickable bullets, in order.
567        assert_eq!(kids[0].on_click(), None, "the title is not clickable");
568        assert_eq!(kids[1].on_click(), Some(Msg::Focus("email")));
569        assert_eq!(kids[2].on_click(), Some(Msg::Focus("password")));
570        assert!(kids[1].focusable(), "a clickable bullet is focusable");
571        // The inert variant does not click.
572        let inert = ErrorSummary::<Msg>::new(["Invalid email"]);
573        let inert_kids = Widget::<Msg>::children(&inert);
574        assert_eq!(inert_kids[1].on_click(), None);
575        assert!(
576            !inert_kids[1].focusable(),
577            "an inert bullet is not focusable"
578        );
579    }
580}