From 0fd8a9487f7aedbb76fd9a9f04723da43ab1beb5 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Sat, 25 Nov 2023 07:43:04 -0800 Subject: [PATCH] Validations --- examples/login.rs | 2 + src/value.rs | 307 +++++++++++++++++++++++++++++++++------ src/widgets/input.rs | 62 ++++++-- src/widgets/stack.rs | 25 +++- src/widgets/switcher.rs | 1 + src/widgets/validated.rs | 2 - 6 files changed, 335 insertions(+), 64 deletions(-) diff --git a/examples/login.rs b/examples/login.rs index 4f5189e..dd1a996 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -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"), ) diff --git a/src/value.rs b/src/value.rs index 9d0eddf..6e483be 100644 --- a/src/value.rs +++ b/src/value.rs @@ -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 Dynamic { 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 Dynamic { /// This function panics if this value is already locked by the current /// thread. pub fn map_ref(&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(&self, map: impl FnOnce(&GenerationalValue) -> 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 Dynamic { }); } + /// 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(&self, mut for_each: F) + where + T: Send + 'static, + F: for<'a> FnMut(&'a GenerationalValue) + 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(&self, mut for_each: F) @@ -223,6 +245,18 @@ impl Dynamic { 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(&self, mut map: F) -> Dynamic + where + T: Send + 'static, + F: for<'a> FnMut(&'a GenerationalValue) -> 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(&self, mut map: F) -> Dynamic @@ -386,9 +420,7 @@ impl Dynamic { 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 { +pub struct GenerationalValue { + /// The stored value. pub value: T, - pub generation: Generation, + generation: Generation, +} + +impl GenerationalValue { + /// 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(self, map: impl FnOnce(T) -> U) -> GenerationalValue { + 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(&self, map: impl for<'a> FnOnce(&'a T) -> U) -> GenerationalValue { + GenerationalValue { + value: map(&self.value), + generation: self.generation, + } + } +} + +impl Deref for GenerationalValue { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl DerefMut for GenerationalValue { + 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( + mut check: Valid, + ) -> impl for<'a> FnMut(&'a GenerationalValue) -> GenerationalValue> + 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( + &self, + dynamic: &Dynamic, + ) -> impl FnMut(ValidationsState, GenerationalValue>) -> 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 (¤t_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) -> 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) -> 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, + 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( + &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); + 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(&self, result: impl IntoDynamic>) -> Dynamic + 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()), + }) + } +} diff --git a/src/widgets/input.rs b/src/widgets/input.rs index c55307a..e59370e 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -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 { /// The value of this widget. pub value: Dynamic, + /// The placeholder text to display when no value is present. + pub placeholder: Value, mask_symbol: Value, mask: CowString, on_key: Option>, @@ -53,9 +55,11 @@ struct CachedLayout { color: Color, generation: Generation, mask_generation: Option, + placeholder_generation: Option, mask_bytes: usize, width: Option, measured: MeasuredText, + placeholder: MeasuredText, } impl CachedLayout { @@ -63,12 +67,14 @@ impl CachedLayout { &self, generation: Generation, mask_generation: Option, + placeholder_generation: Option, width: Option, 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) -> 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, + placeholder: &'a MeasuredText, bytes: usize, masked: bool, cursor: Cursor, @@ -1076,9 +1110,14 @@ where context.stroke_outline::(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( diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 5094440..8fbf605 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -409,9 +409,13 @@ impl Layout { ) -> Size { 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::())); + let known_gutters = gutter.saturating_mul(UPx::new( + (self.children.len() - self.fit_to_content.len()) + .saturating_sub(1) + .cast::(), + )); 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, diff --git a/src/widgets/switcher.rs b/src/widgets/switcher.rs index a1b5371..0928a52 100644 --- a/src/widgets/switcher.rs +++ b/src/widgets/switcher.rs @@ -60,6 +60,7 @@ impl WrapperWidget for Switcher { context.remove_child(&removed); } } + context.invalidate_when_changed(&self.source); available_space } } diff --git a/src/widgets/validated.rs b/src/widgets/validated.rs index 1631c66..e41749f 100644 --- a/src/widgets/validated.rs +++ b/src/widgets/validated.rs @@ -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(),