From f1072674093d04ea45370d131a1b71985cd59d51 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Fri, 24 Nov 2023 14:29:06 -0800 Subject: [PATCH] Validations --- examples/login.rs | 44 +++++--- examples/validation.rs | 52 +++++++++ src/value.rs | 220 ++++++++++++++++++++++++++++++++++++++- src/widget.rs | 15 ++- src/widgets.rs | 2 + src/widgets/input.rs | 1 + src/widgets/validated.rs | 110 ++++++++++++++++++++ 7 files changed, 419 insertions(+), 25 deletions(-) create mode 100644 examples/validation.rs create mode 100644 src/widgets/validated.rs diff --git a/examples/login.rs b/examples/login.rs index 93e95ea..4f5189e 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -1,6 +1,6 @@ use std::process::exit; -use gooey::value::{Dynamic, MapEach}; +use gooey::value::{Dynamic, Validations}; use gooey::widget::MakeWidget; use gooey::widgets::input::{InputValue, MaskedString}; use gooey::widgets::Expand; @@ -10,19 +10,42 @@ use kludgine::figures::units::Lp; fn main() -> gooey::Result { let username = Dynamic::default(); let password = Dynamic::default(); + let validations = Validations::default(); - let valid = - (&username, &password).map_each(|(username, password)| validate(username, password)); + let username_valid = validations.validate(&username, |u: &String| { + if u.is_empty() { + Err("usernames must contain at least one character") + } else { + Ok(()) + } + }); + + let password_valid = validations.validate(&password, |u: &MaskedString| match u.len() { + 0..=7 => Err("passwords must be at least 8 characters long"), + _ => Ok(()), + }); // TODO this should be a grid layout to ensure proper visual alignment. let username_field = "Username" .align_left() - .and(username.clone().into_input()) + .and( + username + .clone() + .into_input() + .validation(username_valid) + .hint("* required"), + ) .into_rows(); let password_field = "Password" .align_left() - .and(password.clone().into_input()) + .and( + password + .clone() + .into_input() + .validation(password_valid) + .hint("* required, 8 characters min"), + ) .into_rows(); let buttons = "Cancel" @@ -36,12 +59,11 @@ fn main() -> gooey::Result { .and( "Log In" .into_button() - .on_click(move |_| { + .on_click(validations.when_valid(move |()| { println!("Welcome, {}", username.get()); exit(0); - }) - .into_default() - .with_enabled(valid), + })) + .into_default(), ) .into_columns(); @@ -57,7 +79,3 @@ fn main() -> gooey::Result { .expand() .run() } - -fn validate(username: &String, password: &MaskedString) -> bool { - !username.is_empty() && !password.is_empty() -} diff --git a/examples/validation.rs b/examples/validation.rs new file mode 100644 index 0000000..26af42c --- /dev/null +++ b/examples/validation.rs @@ -0,0 +1,52 @@ +use gooey::value::{Dynamic, Validations}; +use gooey::widget::MakeWidget; +use gooey::widgets::input::InputValue; +use gooey::Run; +use kludgine::figures::units::Lp; + +fn main() -> gooey::Result { + let text = Dynamic::default(); + let validations = Validations::default(); + + "Hinted" + .and( + text.clone() + .into_input() + .validation(validations.validate(&text, validate_input)) + .hint("* required"), + ) + .and("Not Hinted") + .and( + text.clone() + .into_input() + .validation(validations.validate(&text, validate_input)), + ) + .and( + "Submit" + .into_button() + .on_click(validations.clone().when_valid(move |()| { + println!( + "Success! This callback only happens when all associated validations are valid" + ); + })), + ) + .and("Reset".into_button().on_click(move |()| { + let _value = text.take(); + validations.reset(); + })) + .into_rows() + .width(Lp::inches(6)) + .centered() + .expand() + .run() +} + +fn validate_input(input: &String) -> Result<(), &'static str> { + if input.is_empty() { + Err("This field cannot be empty") + } else if input.trim().is_empty() { + Err("This field must have at least one non-whitespace character") + } else { + Ok(()) + } +} diff --git a/src/value.rs b/src/value.rs index b282701..9d0eddf 100644 --- a/src/value.rs +++ b/src/value.rs @@ -4,7 +4,9 @@ use std::cell::Cell; use std::fmt::{Debug, Display}; use std::future::Future; use std::ops::{Deref, DerefMut, Not}; +use std::panic::UnwindSafe; use std::str::FromStr; +use std::sync::atomic::{self, AtomicBool}; use std::sync::{Arc, Mutex, MutexGuard, TryLockError}; use std::task::{Poll, Waker}; use std::thread::ThreadId; @@ -495,6 +497,28 @@ impl Dynamic { { Radio::new(widget_value, self.clone(), label) } + + /// Validates the contents of this dynamic using the `check` function, + /// returning a dynamic that contains the validation status. + #[must_use] + pub fn validate_with(&self, mut check: Valid) -> Dynamic + where + T: Send + 'static, + Valid: for<'a> FnMut(&'a T) -> Result<(), E> + Send + 'static, + E: Display, + { + let validation = Dynamic::new(Validation::None); + self.for_each({ + let validation = validation.clone(); + move |value| { + validation.set(match check(value) { + Ok(()) => Validation::Valid, + Err(err) => Validation::Invalid(err.to_string()), + }); + } + }); + validation + } } impl Dynamic { @@ -670,7 +694,7 @@ impl DynamicData { F: for<'a> FnMut() + Send + 'static, { let state = self.state().expect("deadlocked"); - let mut callbacks = state.callbacks.lock().ignore_poison(); + let mut callbacks = state.callbacks.callbacks.lock().ignore_poison(); callbacks.push(Box::new(map)); } @@ -716,7 +740,7 @@ impl Display for DeadlockError { struct State { wrapped: GenerationalValue, - callbacks: Arc>>>, + callbacks: Arc, windows: AHashSet, widgets: AHashSet<(WindowHandle, WidgetId)>, wakers: Vec, @@ -753,14 +777,36 @@ where } } -struct ChangeCallbacks(Arc>>>); +#[derive(Default)] +struct ChangeCallbacksData { + callbacks: Mutex>>, + currently_executing: AtomicBool, +} + +struct ChangeCallbacks(Arc); impl Drop for ChangeCallbacks { fn drop(&mut self) { - if let Ok(mut callbacks) = self.0.lock() { + if self + .0 + .currently_executing + .compare_exchange( + false, + true, + atomic::Ordering::Release, + atomic::Ordering::Acquire, + ) + .is_ok() + { + let mut callbacks = self.0.callbacks.lock().ignore_poison(); for callback in &mut *callbacks { callback.changed(); } + self.0 + .currently_executing + .store(false, atomic::Ordering::Release); + } else { + tracing::warn!("Could not invoke dynamic callbacks because they are already running on this thread"); } } } @@ -1600,3 +1646,169 @@ macro_rules! impl_tuple_map_each_cloned { } impl_all_tuples!(impl_tuple_map_each_cloned); + +/// The status of validating data. +#[derive(Debug, Default, Clone, Eq, PartialEq)] +pub enum Validation { + /// No validation has been performed yet. + /// + /// This status represents that the data is still in its initial state, so + /// errors should be delayed until it is changed. + #[default] + None, + /// The data is valid. + Valid, + /// The data is invalid. The string contains a human-readable message. + Invalid(String), +} + +impl Validation { + /// Returns the effective text to display along side the field. + /// + /// When there is a validation error, it is returned, otherwise the hint is + /// returned. + #[must_use] + pub fn message<'a>(&'a self, hint: &'a str) -> &'a str { + match self { + Validation::None | Validation::Valid => hint, + Validation::Invalid(err) => err, + } + } + + /// Returns true if there is a validation error. + #[must_use] + pub const fn is_error(&self) -> bool { + matches!(self, Self::Invalid(_)) + } +} + +/// A grouping of validations that can be checked simultaneously. +#[derive(Debug, Default, Clone)] +pub struct Validations { + state: Dynamic, + invalid: Dynamic, +} + +#[derive(Default, Debug, Eq, PartialEq, Clone)] +enum ValidationsState { + #[default] + Initial, + Resetting, + Checked, +} + +impl Validations { + /// Validates `dynamic`'s contents using `check`, returning a dynamic + /// containing the validation status. + /// + /// The validation is linked with `self` such that checking `self`'s + /// validation status will include this validation. + #[must_use] + pub fn validate( + &self, + dynamic: &Dynamic, + mut check: Valid, + ) -> Dynamic + where + T: Send + 'static, + Valid: for<'a> FnMut(&'a T) -> Result<(), E> + Send + 'static, + E: Display, + { + let validation = Dynamic::new(Validation::None); + self.invalid.map_mut(|invalid| *invalid += 1); + + let error_message = dynamic.map_each(move |value| match check(value) { + Ok(()) => None, + Err(err) => Some(err.to_string()), + }); + + (&self.state, &error_message).for_each_cloned({ + let validation = validation.clone(); + let invalid_count = self.invalid.clone(); + let state = self.state.clone(); + let dynamic = dynamic.clone(); + let mut initial_generation = dynamic.generation(); + let mut invalid = true; + + move |(current_state, message)| { + let new_status = if let Some(err) = message { + if !invalid { + invalid_count.map_mut(|invalid| *invalid += 1); + invalid = true; + } + Validation::Invalid(err.to_string()) + } else { + if invalid { + invalid_count.map_mut(|invalid| *invalid -= 1); + invalid = false; + } + Validation::Valid + }; + match current_state { + ValidationsState::Resetting => { + initial_generation = dynamic.generation(); + let state = state.clone(); + validation.set(Validation::None); + Duration::ZERO + .on_complete(move || { + state.set(ValidationsState::Initial); + }) + .launch(); + } + ValidationsState::Initial if initial_generation == dynamic.generation() => {} + _ => { + validation.set(new_status); + } + } + } + }); + + validation + } + + /// Returns true if this set of validations are all valid. + #[must_use] + pub fn is_valid(&self) -> bool { + self.invoke_callback((), &mut |()| true) + } + + fn invoke_callback(&self, t: T, handler: &mut F) -> R + where + F: FnMut(T) -> R + UnwindSafe + Send + 'static, + R: Default, + { + let mut state = self.state.lock(); + if let ValidationsState::Initial = &*state { + *state = ValidationsState::Checked; + } + drop(state); + if self.invalid.get() == 0 { + handler(t) + } else { + R::default() + } + } + + /// Returns a function that invokes `handler` only when all tracked + /// validations are valid. + /// + /// The returned function can be use in a + /// [`Callback`](crate::widget::Callback). + /// + /// When the contents are invalid, `R::default()` is returned. + pub fn when_valid( + self, + mut handler: F, + ) -> impl FnMut(T) -> R + UnwindSafe + Send + 'static + where + F: FnMut(T) -> R + UnwindSafe + Send + 'static, + R: Default, + { + move |t: T| self.invoke_callback(t, &mut handler) + } + + /// Resets the validation status for all related validations. + pub fn reset(&self) { + self.state.set(ValidationsState::Resetting); + } +} diff --git a/src/widget.rs b/src/widget.rs index 519b598..a85d0e1 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -25,11 +25,11 @@ use crate::styles::{ }; use crate::tree::Tree; use crate::utils::IgnorePoison; -use crate::value::{IntoDynamic, IntoValue, Value}; +use crate::value::{IntoDynamic, IntoValue, Validation, Value}; use crate::widgets::checkbox::{Checkable, CheckboxState}; use crate::widgets::{ Align, Button, Checkbox, Collapse, Container, Expand, Resize, Scroll, Space, Stack, Style, - Themed, ThemedMode, + Themed, ThemedMode, Validated, }; use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior}; use crate::{ConstraintLimit, Run}; @@ -882,6 +882,11 @@ pub trait MakeWidget: Sized { fn collapse_vertically(self, collapse_when: impl IntoDynamic) -> Collapse { Collapse::vertical(collapse_when, self) } + + /// Returns a widget that shows validation errors and/or hints. + fn validation(self, validation: impl IntoDynamic) -> Validated { + Validated::new(validation, self) + } } /// A type that can create a [`WidgetInstance`] with a preallocated @@ -921,12 +926,6 @@ impl MakeWidget for Color { } } -impl MakeWidget for () { - fn make_widget(self) -> WidgetInstance { - Space::clear().make_widget() - } -} - /// A type that represents whether an event has been handled or ignored. pub type EventHandling = ControlFlow; diff --git a/src/widgets.rs b/src/widgets.rs index 9a74c06..0324e24 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -23,6 +23,7 @@ mod style; mod switcher; mod themed; mod tilemap; +mod validated; pub use align::Align; pub use button::Button; @@ -47,3 +48,4 @@ pub use style::Style; pub use switcher::Switcher; pub use themed::Themed; pub use tilemap::TileMap; +pub use validated::Validated; diff --git a/src/widgets/input.rs b/src/widgets/input.rs index 7c63bf4..c55307a 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -575,6 +575,7 @@ where } }); + context.apply_current_font_settings(); let mut text = Text::new(text, color); if let Some(width) = width { text = text.wrap_at(width); diff --git a/src/widgets/validated.rs b/src/widgets/validated.rs new file mode 100644 index 0000000..1631c66 --- /dev/null +++ b/src/widgets/validated.rs @@ -0,0 +1,110 @@ +use std::fmt::Debug; + +use kludgine::figures::units::Lp; +use kludgine::Color; + +use crate::styles::components::{LineHeight, OutlineColor, TextColor, TextSize}; +use crate::value::{Dynamic, IntoDynamic, IntoValue, MapEach, Validation, Value}; +use crate::widget::{MakeWidget, WidgetInstance, WidgetRef, WrapperWidget}; + +/// A widget that displays validation information around another widget. +/// +/// This widget overrides the outline color of its child to be the theme's error +/// color. +/// +/// Additionally, a message may be shown below the content widget. If there is a +/// validation error, it is shown. Otherwise, an optional hint message is +/// supported. +#[derive(Debug)] +pub struct Validated { + hint: Value, + validation: Dynamic, + validated: WidgetInstance, +} + +impl Validated { + /// Returns a widget that displays validation information around `validated` + /// based on `validation`. + #[must_use] + pub fn new(validation: impl IntoDynamic, validated: impl MakeWidget) -> Self { + Self { + validation: validation.into_dynamic(), + validated: validated.make_widget(), + hint: Value::default(), + } + } + + /// Sets the hint message to be displayed when there is no validation error. + #[must_use] + pub fn hint(mut self, hint: impl IntoValue) -> Self { + self.hint = hint.into_value(); + self + } +} + +impl MakeWidget for Validated { + fn make_widget(self) -> WidgetInstance { + let message = match self.hint { + Value::Constant(hint) => self + .validation + .map_each(move |validation| validation.message(&hint).to_string()), + Value::Dynamic(hint) => (&hint, &self.validation) + .map_each(move |(hint, validation)| validation.message(hint).to_string()), + }; + let collapse = message.map_each(String::is_empty); + + let error_color = Dynamic::new(Color::CLEAR_BLACK); + let default_color = Dynamic::new(Color::CLEAR_BLACK); + let color = (&self.validation, &error_color, &default_color).map_each( + |(validation, error, default)| { + if validation.is_error() { + *error + } else { + *default + } + }, + ); + + ValidatedWidget { + contents: WidgetRef::new( + self.validated + .with(&OutlineColor, color.clone()) + .and( + message + .with(&TextColor, color) + // TODO these should be components + .with(&TextSize, Lp::points(9)) + .with(&LineHeight, Lp::points(13)) + .collapse_vertically(collapse) + .align_left(), + ) + .into_rows(), + ), + error_color, + default_color, + } + .make_widget() + } +} + +#[derive(Debug)] +struct ValidatedWidget { + contents: WidgetRef, + error_color: Dynamic, + default_color: Dynamic, +} + +impl WrapperWidget for ValidatedWidget { + fn child_mut(&mut self) -> &mut WidgetRef { + &mut self.contents + } + + fn redraw_background( + &mut self, + context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>, + ) { + // TODO move these to components. + self.error_color.set(context.theme().error.color); + self.default_color.set(context.theme().surface.outline); + } +}