From 3397188c43095401d8dc4bb9bc999cefcbf64e59 Mon Sep 17 00:00:00 2001 From: Marc Espin Date: Sun, 5 Jul 2026 11:28:50 +0200 Subject: [PATCH] feat(components): Input type transform --- crates/freya-components/src/input.rs | 129 ++++++++++++------ crates/freya-components/src/theming/macros.rs | 8 +- examples/component_input_type.rs | 39 ++++++ 3 files changed, 133 insertions(+), 43 deletions(-) create mode 100644 examples/component_input_type.rs diff --git a/crates/freya-components/src/input.rs b/crates/freya-components/src/input.rs index dd9a07a14..f0198df9f 100644 --- a/crates/freya-components/src/input.rs +++ b/crates/freya-components/src/input.rs @@ -28,7 +28,7 @@ use crate::{ }; define_theme! { - for = Input; + for = Input; theme_field = theme_layout; %[component] @@ -40,7 +40,7 @@ define_theme! { } define_theme! { - for = Input; + for = Input; theme_field = theme_colors; %[component] @@ -115,6 +115,45 @@ impl InputValidator { } } +/// Value type that an [Input] can edit through its text representation. +pub trait InputType: Clone + PartialEq + 'static { + /// Text representation of the value. + fn as_text(&self) -> Cow<'_, str>; + + /// Parse the text back into a value, `None` if the text is not valid. + fn from_text(text: &str) -> Option; +} + +impl InputType for String { + fn as_text(&self) -> Cow<'_, str> { + Cow::Borrowed(self) + } + + fn from_text(text: &str) -> Option { + Some(text.to_owned()) + } +} + +macro_rules! impl_input_type_parseable { + ($($ty:ty),*) => { + $( + impl InputType for $ty { + fn as_text(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } + + fn from_text(text: &str) -> Option { + text.parse().ok() + } + } + )* + }; +} + +impl_input_type_parseable!( + u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64 +); + /// Small box to write some text. /// /// ## **Normal** @@ -157,6 +196,18 @@ impl InputValidator { /// # }, "./images/gallery_flat_input.png").render(); /// ``` /// +/// ## **Value types** +/// +/// The value can be of any type implementing [InputType], for example numbers. +/// +/// ```rust +/// # use freya::prelude::*; +/// fn app() -> impl IntoElement { +/// let value = use_state(|| 50u8); +/// Input::new(value).placeholder("Age") +/// } +/// ``` +/// /// # Preview /// ![Input Preview][input] /// ![Filled Input Preview][filled_input] @@ -167,13 +218,13 @@ impl InputValidator { doc = embed_doc_image::embed_image!("flat_input", "images/gallery_flat_input.png"), )] #[derive(Clone, PartialEq)] -pub struct Input { +pub struct Input { pub(crate) theme_colors: Option, pub(crate) theme_layout: Option, - value: Writable, + value: Writable, placeholder: Option>, on_validate: Option>, - on_submit: Option>, + on_submit: Option>, mode: InputMode, auto_focus: bool, width: Size, @@ -188,14 +239,14 @@ pub struct Input { on_pre_key_down: Callback, bool>, } -impl KeyExt for Input { +impl KeyExt for Input { fn write_key(&mut self) -> &mut DiffKey { &mut self.key } } -impl Input { - pub fn new(value: impl Into>) -> Self { +impl Input { + pub fn new(value: impl Into>) -> Self { Input { theme_colors: None, theme_layout: None, @@ -241,7 +292,7 @@ impl Input { self } - pub fn on_submit(mut self, on_submit: impl Into>) -> Self { + pub fn on_submit(mut self, on_submit: impl Into>) -> Self { self.on_submit = Some(on_submit.into()); self } @@ -334,13 +385,13 @@ impl Input { } } -impl CornerRadiusExt for Input { +impl CornerRadiusExt for Input { fn with_corner_radius(self, corner_radius: f32) -> Self { self.corner_radius(corner_radius) } } -impl Component for Input { +impl Component for Input { fn render(&self) -> impl IntoElement { let a11y_id = use_hook(|| self.a11y_id.unwrap_or_else(AccessibilityId::new_unique)); let focus = use_focus(a11y_id); @@ -349,7 +400,7 @@ impl Component for Input { let mut status = use_state(InputStatus::default); let allow_write_clipboard = !matches!(self.mode, InputMode::Hidden(_)); let mut editable = use_editable( - || self.value.read().to_string(), + || self.value.read().as_text().into_owned(), move || EditableConfig::new().with_allow_write_clipboard(allow_write_clipboard), ); let mut is_dragging = use_state(|| false); @@ -396,20 +447,23 @@ impl Component for Input { } }); - let display_placeholder = value.read().is_empty() - && self.placeholder.is_some() - && !editable.editor().read().has_preedit(); let on_validate = self.on_validate.clone(); let on_submit = self.on_submit.clone(); - if *value.read() != editable.editor().read().committed_text() { + let editor_value = T::from_text(&editable.editor().read().committed_text()); + if editor_value.as_ref() != Some(&*self.value.read()) { let mut editor = editable.editor_mut().write(); editor.clear_preedit(); - editor.set(&value.read()); + editor.set(&self.value.read().as_text()); editor.editor_history().clear(); editor.clear_selection(); } + let display_placeholder = { + let editor = editable.editor().read(); + editor.rope().len_chars() == 0 && self.placeholder.is_some() && !editor.has_preedit() + }; + let on_ime_preedit = move |e: Event| { let mut editor = editable.editor_mut().write(); if e.data().text.is_empty() { @@ -432,8 +486,7 @@ impl Component for Input { // On submit Key::Named(NamedKey::Enter) => { if let Some(on_submit) = &on_submit { - let text = editable.editor().peek().committed_text(); - on_submit.call(text); + on_submit.call(value.peek().clone()); } } // On unfocus @@ -450,24 +503,20 @@ impl Component for Input { }); let text = editable.editor().read().committed_text(); - let apply_change = match &on_validate { - Some(on_validate) => { - let mut editor = editable.editor_mut().write(); - let validator = InputValidator::new(text.clone()); - on_validate.call(validator.clone()); - if !validator.is_valid() { - if let Some(selection) = editor.undo() { - *editor.selection_mut() = selection; - } - editor.editor_history().clear_redos(); - } - validator.is_valid() - } - None => true, - }; + let is_valid = on_validate.as_ref().is_none_or(|on_validate| { + let validator = InputValidator::new(text.clone()); + on_validate.call(validator.clone()); + validator.is_valid() + }); - if apply_change { - *value.write() = text; + if let Some(new_value) = is_valid.then(|| T::from_text(&text)).flatten() { + *value.write() = new_value; + } else { + let mut editor = editable.editor_mut().write(); + if let Some(selection) = editor.undo() { + *editor.selection_mut() = selection; + } + editor.editor_history().clear_redos(); } } } @@ -625,11 +674,13 @@ impl Component for Input { theme_colors.color }; - let value = self.value.read(); + let committed_text = editable.editor().read().committed_text(); let a11y_text: Cow = match (self.mode.clone(), &self.placeholder) { (_, Some(ph)) if display_placeholder => Cow::Borrowed(ph.as_ref()), - (InputMode::Hidden(ch), _) => Cow::Owned(ch.to_string().repeat(value.len())), - (InputMode::Shown, _) => Cow::Borrowed(value.as_ref()), + (InputMode::Hidden(ch), _) => { + Cow::Owned(ch.to_string().repeat(committed_text.chars().count())) + } + (InputMode::Shown, _) => Cow::Owned(committed_text), }; let a11_role = match self.mode { diff --git a/crates/freya-components/src/theming/macros.rs b/crates/freya-components/src/theming/macros.rs index 2907a58f1..40d8a73d3 100644 --- a/crates/freya-components/src/theming/macros.rs +++ b/crates/freya-components/src/theming/macros.rs @@ -16,13 +16,13 @@ macro_rules! define_theme { ( @ext_impls - [ $head_ty:ident $($rest_ty:ident)* ] + [ { $head_ty:ident $(< $($head_generics:ident),* >)? } $($rest_ty:tt)* ] [ $head_field:ident $($rest_field:ident)* ] $name:ident ; $( $(#[$field_attrs:meta])* $field_name:ident : $field_ty:ty , )* ) => { $crate::theming::macros::paste! { - impl [<$name ThemePartialExt>] for $head_ty { + impl $(< $($head_generics: 'static),* >)? [<$name ThemePartialExt>] for $head_ty $(< $($head_generics),* >)? { $( $(#[$field_attrs])* fn $field_name(mut self, $field_name: impl Into<$field_ty>) -> Self { @@ -136,7 +136,7 @@ macro_rules! define_theme { ( $(#[$attrs:meta])* - $(for = $for_ty:ident ; theme_field = $theme_field:ident ;)+ + $(for = $for_ty:ident $(< $($for_generics:ident),* >)? ; theme_field = $theme_field:ident ;)+ $(%[component$($component_attr_control:tt)?])? pub $name:ident { $( @@ -171,7 +171,7 @@ macro_rules! define_theme { } $crate::define_theme! { @ext_impls - [ $($for_ty)+ ] + [ $({ $for_ty $(< $($for_generics),* >)? })+ ] [ $($theme_field)+ ] $name ; $($( $(#[$field_attrs])* $field_name : $field_ty , )*)? diff --git a/examples/component_input_type.rs b/examples/component_input_type.rs new file mode 100644 index 000000000..246bd7383 --- /dev/null +++ b/examples/component_input_type.rs @@ -0,0 +1,39 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use freya::prelude::*; + +fn main() { + launch(LaunchConfig::new().with_window(WindowConfig::new(app))) +} + +fn app() -> impl IntoElement { + let quantity = use_state(|| 2); + let price = use_state(|| 4.5); + + let total = quantity() as f64 * price(); + + rect() + .center() + .expanded() + .spacing(8.) + .child( + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::center()) + .child("Quantity") + .child(Input::new(quantity).placeholder("Quantity")), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::center()) + .child("Price") + .child(Input::new(price).placeholder("Price")), + ) + .child(format!("Total: {total:.2}")) +}