1use 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
42type Check = Box<dyn Fn(&str) -> Option<String>>;
44
45pub struct Rule(Check);
48
49impl Rule {
50 pub fn new(check: impl Fn(&str) -> Option<String> + 'static) -> Self {
52 Self(Box::new(check))
53 }
54
55 pub fn check(&self, value: &str) -> Option<String> {
57 (self.0)(value)
58 }
59
60 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 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 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 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 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
92fn is_email(v: &str) -> bool {
94 let v = v.trim();
95 let Some((local, domain)) = v.split_once('@') else {
96 return false;
97 };
98 if local.is_empty() || domain.contains('@') {
100 return false;
101 }
102 domain.contains('.') && domain.split('.').all(|label| !label.is_empty())
104}
105
106#[derive(Default)]
110pub struct Form {
111 fields: Vec<(&'static str, String, Option<String>)>,
112}
113
114impl Form {
115 pub fn new() -> Self {
117 Self::default()
118 }
119
120 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 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 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 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 pub fn is_valid(&self) -> bool {
167 self.fields.iter().all(|(_, _, e)| e.is_none())
168 }
169
170 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 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 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
197const SUMMARY_PAD: f32 = 12.0;
199
200pub 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 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 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 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 #[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 #[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 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 pub fn is_empty(&self) -> bool {
287 self.empty
288 }
289}
290
291fn 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
306fn 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
316struct 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 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 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 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 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 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 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 assert_eq!(rule.check(""), Some("Required".to_string()));
458 assert_eq!(rule.check("nope"), Some("Invalid email".to_string()));
460 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 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 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 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 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 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}