Validations

This commit is contained in:
Jonathan Johnson 2023-11-25 07:43:04 -08:00
parent f107267409
commit 0fd8a9487f
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
6 changed files with 335 additions and 64 deletions

View file

@ -32,6 +32,7 @@ fn main() -> gooey::Result {
username
.clone()
.into_input()
.placeholder("Username")
.validation(username_valid)
.hint("* required"),
)
@ -43,6 +44,7 @@ fn main() -> gooey::Result {
password
.clone()
.into_input()
.placeholder("Password")
.validation(password_valid)
.hint("* required, 8 characters min"),
)

View file

@ -1,6 +1,5 @@
//! Types for storing and interacting with values in Widgets.
use std::cell::Cell;
use std::fmt::{Debug, Display};
use std::future::Future;
use std::ops::{Deref, DerefMut, Not};
@ -13,7 +12,6 @@ use std::thread::ThreadId;
use std::time::Duration;
use ahash::AHashSet;
use intentional::Assert;
use crate::animation::{DynamicTransition, IntoAnimate, LinearInterpolate, Spawn};
use crate::context::sealed::WindowHandle;
@ -78,7 +76,7 @@ impl<T> Dynamic<T> {
RIntoT: FnMut(&R) -> RIntoTResult + Send + 'static,
{
let initial_r = self
.map_ref(&mut t_into_r)
.map_ref(|v| t_into_r(v))
.into()
.expect("t_into_r must succeed with the current value");
let r = Dynamic::new(initial_r);
@ -122,8 +120,19 @@ impl<T> Dynamic<T> {
/// This function panics if this value is already locked by the current
/// thread.
pub fn map_ref<R>(&self, map: impl FnOnce(&T) -> R) -> R {
self.map_generational(|gen| map(&gen.value))
}
/// Maps the contents with read-only access, providing access to the value's
/// [`Generation`].
///
/// # Panics
///
/// This function panics if this value is already locked by the current
/// thread.
pub fn map_generational<R>(&self, map: impl FnOnce(&GenerationalValue<T>) -> R) -> R {
let state = self.state().expect("deadlocked");
map(&state.wrapped.value)
map(&state.wrapped)
}
/// Maps the contents with exclusive access. Before returning from this
@ -186,6 +195,19 @@ impl<T> Dynamic<T> {
});
}
/// Attaches `for_each` to this value and its [`Generation`] so that it is
/// invoked each time the value's contents are updated.
pub fn for_each_generational<F>(&self, mut for_each: F)
where
T: Send + 'static,
F: for<'a> FnMut(&'a GenerationalValue<T>) + Send + 'static,
{
let this = self.clone();
self.0.for_each(move || {
this.map_generational(&mut for_each);
});
}
/// Attaches `for_each` to this value so that it is invoked each time the
/// value's contents are updated.
pub fn for_each_cloned<F>(&self, mut for_each: F)
@ -223,6 +245,18 @@ impl<T> Dynamic<T> {
self.0.map_each(move || this.map_ref(&mut map))
}
/// Creates a new dynamic value that contains the result of invoking `map`
/// each time this value is changed.
pub fn map_each_generational<R, F>(&self, mut map: F) -> Dynamic<R>
where
T: Send + 'static,
F: for<'a> FnMut(&'a GenerationalValue<T>) -> R + Send + 'static,
R: PartialEq + Send + 'static,
{
let this = self.clone();
self.0.map_each(move || this.map_generational(&mut map))
}
/// Creates a new dynamic value that contains the result of invoking `map`
/// each time this value is changed.
pub fn map_each_cloned<R, F>(&self, mut map: F) -> Dynamic<R>
@ -386,9 +420,7 @@ impl<T> Dynamic<T> {
where
T: PartialEq,
{
let cell = Cell::new(Some(new_value));
match self.0.map_mut(|value, changed| {
let new_value = cell.take().assert("only one callback will be invoked");
if *value == new_value {
*changed = false;
Err(ReplaceError::NoChange(new_value))
@ -824,10 +856,58 @@ where
}
}
/// A value stored in a [`Dynamic`] with its [`Generation`].
#[derive(Clone, Debug, Eq, PartialEq)]
struct GenerationalValue<T> {
pub struct GenerationalValue<T> {
/// The stored value.
pub value: T,
pub generation: Generation,
generation: Generation,
}
impl<T> GenerationalValue<T> {
/// Returns the generation of this value.
///
/// Each time a [`Dynamic`] is updated, the generation is also updated. This
/// value can be used to track whether a particular value has been observed.
pub const fn generation(&self) -> Generation {
self.generation
}
/// Returns a new instance containing the result of invoking `map` with
/// `self.value`.
///
/// The returned instance will have the same generation as this instance.
pub fn map<U>(self, map: impl FnOnce(T) -> U) -> GenerationalValue<U> {
GenerationalValue {
value: map(self.value),
generation: self.generation,
}
}
/// Returns a new instance containing the result of invoking `map` with
/// `&self.value`.
///
/// The returned instance will have the same generation as this instance.
pub fn map_ref<U>(&self, map: impl for<'a> FnOnce(&'a T) -> U) -> GenerationalValue<U> {
GenerationalValue {
value: map(&self.value),
generation: self.generation,
}
}
}
impl<T> Deref for GenerationalValue<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl<T> DerefMut for GenerationalValue<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.value
}
}
/// An exclusive reference to the contents of a [`Dynamic`].
@ -1695,6 +1775,7 @@ enum ValidationsState {
Initial,
Resetting,
Checked,
Disabled,
}
impl Validations {
@ -1715,57 +1796,109 @@ impl Validations {
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()),
});
let mut message_mapping = Self::map_to_message(move |value| check(value));
let error_message = dynamic.map_each_generational(move |value| message_mapping(value));
(&self.state, &error_message).for_each_cloned({
let mut f = self.generate_validation(dynamic);
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.set(f(current_state, message));
}
});
validation
}
fn map_to_message<T, E, Valid>(
mut check: Valid,
) -> impl for<'a> FnMut(&'a GenerationalValue<T>) -> GenerationalValue<Option<String>> + Send + 'static
where
T: Send + 'static,
Valid: for<'a> FnMut(&'a T) -> Result<(), E> + Send + 'static,
E: Display,
{
move |value| {
value.map_ref(|value| match check(value) {
Ok(()) => None,
Err(err) => Some(err.to_string()),
})
}
}
fn generate_validation<T>(
&self,
dynamic: &Dynamic<T>,
) -> impl FnMut(ValidationsState, GenerationalValue<Option<String>>) -> Validation
where
T: Send + 'static,
{
self.invalid.map_mut(|invalid| *invalid += 1);
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, generational| {
let new_invalid = match (&current_state, &generational.value) {
(ValidationsState::Disabled, _) | (_, None) => false,
(_, Some(_)) => true,
};
if invalid != new_invalid {
if new_invalid {
invalid_count.map_mut(|invalid| *invalid += 1);
} else {
invalid_count.map_mut(|invalid| *invalid -= 1);
}
invalid = new_invalid;
}
let new_status = if let Some(err) = generational.value {
Validation::Invalid(err.to_string())
} else {
Validation::Valid
};
match current_state {
ValidationsState::Resetting => {
initial_generation = dynamic.generation();
let state = state.clone();
Duration::ZERO
.on_complete(move || {
state.set(ValidationsState::Initial);
})
.launch();
Validation::None
}
ValidationsState::Initial if initial_generation == dynamic.generation() => {
Validation::None
}
_ => new_status,
}
}
}
/// Returns a builder that can be used to create validations that only run
/// when `condition` is true.
pub fn when(&self, condition: impl IntoDynamic<bool>) -> WhenValidation<'_> {
WhenValidation {
validations: self,
condition: condition.into_dynamic(),
not: false,
}
}
/// Returns a builder that can be used to create validations that only run
/// when `condition` is false.
pub fn when_not(&self, condition: impl IntoDynamic<bool>) -> WhenValidation<'_> {
WhenValidation {
validations: self,
condition: condition.into_dynamic(),
not: true,
}
}
/// Returns true if this set of validations are all valid.
#[must_use]
pub fn is_valid(&self) -> bool {
@ -1812,3 +1945,81 @@ impl Validations {
self.state.set(ValidationsState::Resetting);
}
}
/// A builder for validations that only run when a precondition is met.
pub struct WhenValidation<'a> {
validations: &'a Validations,
condition: Dynamic<bool>,
not: bool,
}
impl WhenValidation<'_> {
/// 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.
///
/// Each change to `dynamic` is validated, but the result of the validation
/// will be ignored if the required prerequisite isn't met.
#[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);
let mut map_to_message = Validations::map_to_message(move |value| check(value));
let error_message =
dynamic.map_each_generational(move |generational| map_to_message(generational));
let mut f = self.validations.generate_validation(dynamic);
let not = self.not;
(&self.condition, &self.validations.state, &error_message).map_each_cloned({
let validation = validation.clone();
move |(condition, state, message)| {
let enabled = if not { !condition } else { condition };
let state = if enabled {
state
} else {
ValidationsState::Disabled
};
let result = f(state, message);
if enabled {
validation.set(result);
} else {
validation.set(Validation::None);
}
}
});
validation
}
/// Returns a dynamic validation status that is created by transforming the
/// `Err` variant of `result` using [`Display`].
///
/// The validation is linked with `self` such that checking `self`'s
/// validation status will include this validation.
#[must_use]
pub fn validate_result<E>(&self, result: impl IntoDynamic<Result<(), E>>) -> Dynamic<Validation>
where
E: Display + Send + 'static,
{
let result = result.into_dynamic();
let error_message = result.map_each(move |value| match value {
Ok(()) => None,
Err(err) => Some(err.to_string()),
});
self.validate(&error_message, |error_message| match error_message {
None => Ok(()),
Some(message) => Err(message.clone()),
})
}
}

View file

@ -20,7 +20,7 @@ use kludgine::figures::{
use kludgine::shapes::{Shape, StrokeOptions};
use kludgine::text::{MeasuredText, Text, TextOrigin};
use kludgine::{Color, DrawableExt};
use unicode_segmentation::UnicodeSegmentation;
use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation};
use zeroize::Zeroizing;
use crate::context::{EventContext, GraphicsContext, LayoutContext};
@ -37,6 +37,8 @@ const CURSOR_BLINK_DURATION: Duration = Duration::from_millis(500);
pub struct Input<Storage> {
/// The value of this widget.
pub value: Dynamic<Storage>,
/// The placeholder text to display when no value is present.
pub placeholder: Value<String>,
mask_symbol: Value<CowString>,
mask: CowString,
on_key: Option<Callback<KeyEvent, EventHandling>>,
@ -53,9 +55,11 @@ struct CachedLayout {
color: Color,
generation: Generation,
mask_generation: Option<Generation>,
placeholder_generation: Option<Generation>,
mask_bytes: usize,
width: Option<Px>,
measured: MeasuredText<Px>,
placeholder: MeasuredText<Px>,
}
impl CachedLayout {
@ -63,12 +67,14 @@ impl CachedLayout {
&self,
generation: Generation,
mask_generation: Option<Generation>,
placeholder_generation: Option<Generation>,
width: Option<Px>,
color: Color,
mask_bytes: usize,
) -> bool {
self.generation == generation
&& self.mask_generation == mask_generation
&& self.placeholder_generation == placeholder_generation
&& self.width == width
&& self.color == color
&& self.mask_bytes == mask_bytes
@ -118,6 +124,7 @@ where
.then(|| CowString::from('\u{2022}'))
.unwrap_or_default()
.into_value(),
placeholder: Value::default(),
cache: None,
blink_state: BlinkState::default(),
selection: SelectionState::default(),
@ -128,6 +135,13 @@ where
}
}
/// Sets the `placeholder` text, which is displayed when the field has an
/// empty value.
pub fn placeholder(mut self, placeholder: impl IntoValue<String>) -> Self {
self.placeholder = placeholder.into_value();
self
}
/// Sets the symbol to use for masking sensitive content to `symbol`.
///
/// Only the first unicode grapheme will be used for the symbol. A warning
@ -212,9 +226,14 @@ where
return;
}
// TODO remove a full grapheme
let removed = value.as_string_mut().remove(cursor.offset - 1);
self.selection.cursor.offset -= removed.len_utf8();
if let Ok(Some(offset)) = GraphemeCursor::new(cursor.offset, value.as_str().len(), true)
.prev_boundary(value.as_str(), 0)
{
value
.as_string_mut()
.replace_range(offset..cursor.offset, "");
self.selection.cursor.offset -= cursor.offset - offset;
}
}
}
@ -537,6 +556,7 @@ where
let (mut cursor, mut selection) = self.selected_range();
let generation = self.value.generation();
let mask_generation = self.mask_symbol.generation();
let placeholder_generation = self.placeholder.generation();
let mut mask_bytes = self
.mask_symbol
.map(|sym| sym.graphemes(true).next().map_or(0, str::len));
@ -544,9 +564,16 @@ where
context.invalidate_when_changed(&self.value);
match &mut self.cache {
Some(cache)
if cache.is_current(generation, mask_generation, width, color, mask_bytes) => {}
if cache.is_current(
generation,
mask_generation,
placeholder_generation,
width,
color,
mask_bytes,
) => {}
_ => {
let (bytes, measured) = self.value.map_ref(|storage| {
let (bytes, measured, placeholder, ) = self.value.map_ref(|storage| {
let mut text = storage.as_str();
let mut bytes = text.len();
@ -580,16 +607,21 @@ where
if let Some(width) = width {
text = text.wrap_at(width);
}
(bytes, context.gfx.measure_text(text))
let placeholder_color = context.theme().surface.on_color_variant;
let placeholder = self.placeholder.map(|placeholder| context.gfx.measure_text(Text::new(placeholder, placeholder_color)));
(bytes, context.gfx.measure_text(text), placeholder)
});
self.cache = Some(CachedLayout {
bytes,
color,
generation,
mask_generation,
placeholder_generation,
mask_bytes,
width,
measured,
placeholder,
});
}
}
@ -612,6 +644,7 @@ where
let cache = self.cache.as_ref().expect("always initialized");
CacheInfo {
measured: &cache.measured,
placeholder: &cache.placeholder,
bytes: cache.bytes,
masked: mask_bytes > 0,
cursor,
@ -877,6 +910,7 @@ where
struct CacheInfo<'a> {
measured: &'a MeasuredText<Px>,
placeholder: &'a MeasuredText<Px>,
bytes: usize,
masked: bool,
cursor: Cursor,
@ -1076,9 +1110,14 @@ where
context.stroke_outline::<Lp>(outline_color, StrokeOptions::default());
}
let text = if cache.bytes > 0 {
cache.measured
} else {
cache.placeholder
};
context
.gfx
.draw_measured_text(cache.measured.translate_by(padding), TextOrigin::TopLeft);
.draw_measured_text(text.translate_by(padding), TextOrigin::TopLeft);
}
fn layout(
@ -1092,7 +1131,12 @@ where
let cache = self.layout_text(Some(width.into_signed()), &mut context.graphics);
cache.measured.size.into_unsigned() + Size::squared(padding * 2)
cache
.measured
.size
.max(cache.placeholder.size)
.into_unsigned()
+ Size::squared(padding * 2)
}
fn keyboard_input(

View file

@ -409,9 +409,13 @@ impl Layout {
) -> Size<UPx> {
let (space_constraint, other_constraint) = self.orientation.split_size(available);
let available_space = space_constraint.max();
let gutter_space = gutter.saturating_mul(UPx::new((self.children.len() - 1).cast::<u32>()));
let known_gutters = gutter.saturating_mul(UPx::new(
(self.children.len() - self.fit_to_content.len())
.saturating_sub(1)
.cast::<u32>(),
));
let allocated_space =
self.allocated_space.0 + self.allocated_space.1.into_upx(scale).ceil() + gutter_space;
self.allocated_space.0 + self.allocated_space.1.into_upx(scale).ceil() + known_gutters;
let mut remaining = available_space.saturating_sub(allocated_space);
// If our `other_constraint` is not known, we will need to give child
// widgets an opportunity to lay themselves out in the full area. This
@ -421,7 +425,7 @@ impl Layout {
// Measure the children that fit their content
self.other = UPx::ZERO;
for &id in &self.fit_to_content {
for (fit_index, &id) in self.fit_to_content.iter().enumerate() {
let index = self.children.index_of_id(id).expect("child not found");
let (measured, other) = self.orientation.split_size(measure(
index,
@ -430,7 +434,16 @@ impl Layout {
!needs_final_layout,
));
self.layouts[index].size = measured;
self.other = self.other.max(other);
if measured == 0 {
self.other = UPx::ZERO;
} else {
if fit_index < self.fit_to_content.len() - 1
|| self.fit_to_content.len() != self.children.len()
{
remaining = remaining.saturating_sub(gutter);
}
self.other = self.other.max(other);
}
remaining = remaining.saturating_sub(measured);
}
@ -495,7 +508,9 @@ impl Layout {
let mut offset = UPx::ZERO;
for index in 0..self.children.len() {
self.layouts[index].offset = offset;
offset += self.layouts[index].size + gutter;
if self.layouts[index].size > 0 {
offset += self.layouts[index].size + gutter;
}
if needs_final_layout {
self.orientation.split_size(measure(
index,

View file

@ -60,6 +60,7 @@ impl WrapperWidget for Switcher {
context.remove_child(&removed);
}
}
context.invalidate_when_changed(&self.source);
available_space
}
}

View file

@ -51,7 +51,6 @@ impl MakeWidget for Validated {
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);
@ -75,7 +74,6 @@ impl MakeWidget for Validated {
// TODO these should be components
.with(&TextSize, Lp::points(9))
.with(&LineHeight, Lp::points(13))
.collapse_vertically(collapse)
.align_left(),
)
.into_rows(),