Validations

This commit is contained in:
Jonathan Johnson 2023-11-24 14:29:06 -08:00
parent b2fdf06e60
commit f107267409
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
7 changed files with 419 additions and 25 deletions

View file

@ -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()
}

52
examples/validation.rs Normal file
View file

@ -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(())
}
}

View file

@ -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<T> Dynamic<T> {
{
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<E, Valid>(&self, mut check: Valid) -> Dynamic<Validation>
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<WidgetInstance> {
@ -670,7 +694,7 @@ impl<T> DynamicData<T> {
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<T> {
wrapped: GenerationalValue<T>,
callbacks: Arc<Mutex<Vec<Box<dyn ValueCallback>>>>,
callbacks: Arc<ChangeCallbacksData>,
windows: AHashSet<WindowHandle>,
widgets: AHashSet<(WindowHandle, WidgetId)>,
wakers: Vec<Waker>,
@ -753,14 +777,36 @@ where
}
}
struct ChangeCallbacks(Arc<Mutex<Vec<Box<dyn ValueCallback>>>>);
#[derive(Default)]
struct ChangeCallbacksData {
callbacks: Mutex<Vec<Box<dyn ValueCallback>>>,
currently_executing: AtomicBool,
}
struct ChangeCallbacks(Arc<ChangeCallbacksData>);
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<ValidationsState>,
invalid: Dynamic<usize>,
}
#[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<T, E, Valid>(
&self,
dynamic: &Dynamic<T>,
mut check: Valid,
) -> Dynamic<Validation>
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<T, R, F>(&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<T, R, F>(
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);
}
}

View file

@ -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<bool>) -> Collapse {
Collapse::vertical(collapse_when, self)
}
/// Returns a widget that shows validation errors and/or hints.
fn validation(self, validation: impl IntoDynamic<Validation>) -> 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<EventHandled, EventIgnored>;

View file

@ -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;

View file

@ -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);

110
src/widgets/validated.rs Normal file
View file

@ -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<String>,
validation: Dynamic<Validation>,
validated: WidgetInstance,
}
impl Validated {
/// Returns a widget that displays validation information around `validated`
/// based on `validation`.
#[must_use]
pub fn new(validation: impl IntoDynamic<Validation>, 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<String>) -> 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<Color>,
default_color: Dynamic<Color>,
}
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);
}
}