diff --git a/Cargo.lock b/Cargo.lock index c6921b95..23e6ea32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3609,6 +3609,14 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8686b91785aff82828ed725225925b33b4fde44c4bb15876e5f7c832724c420a" +[[package]] +name = "typed_input" +version = "0.1.0" +dependencies = [ + "iced", + "iced_aw", +] + [[package]] name = "typenum" version = "1.17.0" diff --git a/Cargo.toml b/Cargo.toml index a663ca94..ad04e3c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,8 @@ tab_bar = [] tabs = ["tab_bar"] time_picker = ["chrono", "icons", "iced/canvas"] wrap = [] -number_input = ["num-format", "num-traits"] +number_input = ["num-format", "num-traits", "typed_input"] +typed_input = [] selection_list = [] menu = [] quad = [] @@ -82,6 +83,7 @@ members = [ "examples/badge", "examples/card", "examples/number_input", + "examples/typed_input", "examples/date_picker", "examples/color_picker", "examples/grid", diff --git a/examples/number_input/src/main.rs b/examples/number_input/src/main.rs index 3093acd2..ea64b44a 100644 --- a/examples/number_input/src/main.rs +++ b/examples/number_input/src/main.rs @@ -31,12 +31,13 @@ fn main() -> iced::Result { impl NumberInputDemo { fn update(&mut self, message: self::Message) { let Message::NumInpChanged(val) = message; + println!("Value changed to {:?}", val); self.value = val; } fn view(&self) -> Element { let lb_minute = Text::new("Number Input:"); - let txt_minute = number_input(self.value, 0.0..250.0, Message::NumInpChanged) + let txt_minute = number_input(self.value, -10.0..250.0, Message::NumInpChanged) .style(number_input::number_input::primary) .step(0.5); diff --git a/examples/typed_input/Cargo.toml b/examples/typed_input/Cargo.toml new file mode 100644 index 00000000..37a785cd --- /dev/null +++ b/examples/typed_input/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "typed_input" +version = "0.1.0" +authors = ["Ultraxime <36888699+Ultraxime@users.noreply.github.com>"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +iced_aw = { workspace = true, features = [ + "typed_input", + "icons", +] } + +iced.workspace=true diff --git a/examples/typed_input/src/main.rs b/examples/typed_input/src/main.rs new file mode 100644 index 00000000..aa67b22a --- /dev/null +++ b/examples/typed_input/src/main.rs @@ -0,0 +1,55 @@ +use iced::{ + widget::{Container, Row, Text}, + Alignment, Element, Length, +}; +use iced_aw::widgets::typed_input; + +#[derive(Default, Debug)] +pub struct TypedInputDemo { + value: f32, +} + +#[derive(Debug, Clone)] +pub enum Message { + TypedInpChanged(f32), +} + +fn main() -> iced::Result { + iced::application( + "Typed Input example", + TypedInputDemo::update, + TypedInputDemo::view, + ) + .window_size(iced::Size { + width: 250.0, + height: 200.0, + }) + .font(iced_aw::BOOTSTRAP_FONT_BYTES) + .run() +} + +impl TypedInputDemo { + fn update(&mut self, message: self::Message) { + let Message::TypedInpChanged(val) = message; + println!("Value changed to {:?}", val); + self.value = val; + } + + fn view(&self) -> Element { + let lb_minute = Text::new("Typed Input:"); + let txt_minute = typed_input::TypedInput::new(self.value, Message::TypedInpChanged); + + Container::new( + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push(lb_minute) + .push(txt_minute), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } +} diff --git a/src/widgets.rs b/src/widgets.rs index eec13f8e..32d5e4b7 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -23,6 +23,9 @@ pub mod number_input; pub type NumberInput<'a, T, Message, Theme, Renderer> = number_input::NumberInput<'a, T, Message, Theme, Renderer>; +#[cfg(feature = "typed_input")] +pub mod typed_input; + #[cfg(feature = "card")] pub mod card; #[cfg(feature = "card")] diff --git a/src/widgets/number_input.rs b/src/widgets/number_input.rs index 24350e6f..0428c7d3 100644 --- a/src/widgets/number_input.rs +++ b/src/widgets/number_input.rs @@ -17,7 +17,7 @@ use iced::{ widget::{ text::LineHeight, text_input::{self, cursor, Value}, - Column, Container, Row, Text, TextInput, + Column, Container, Row, Text, }, Alignment, Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow, Size, @@ -37,6 +37,7 @@ pub use crate::{ StyleFn, }, }; +use crate::widgets::typed_input::TypedInput; /// The default padding const DEFAULT_PADDING: f32 = 5.0; @@ -81,7 +82,7 @@ where /// The text size of the [`NumberInput`]. size: Option, /// The underlying element of the [`NumberInput`]. - content: TextInput<'a, Message, Theme, Renderer>, + content: TypedInput<'a, T, Message, Theme, Renderer>, /// The ``on_change`` event of the [`NumberInput`]. on_change: Box Message>, /// The style of the [`NumberInput`]. @@ -116,9 +117,6 @@ where T: 'static, { let padding = DEFAULT_PADDING; - let convert_to_num = move |s: String| { - on_changed(T::from_str(&s).unwrap_or(if s.is_empty() { T::zero() } else { value })) - }; Self { value, @@ -127,8 +125,7 @@ where max: Self::set_max(bounds.end_bound()), padding, size: None, - content: TextInput::new("", format!("{value}").as_str()) - .on_input(convert_to_num) + content: TypedInput::new(value, on_changed) .padding(padding) .width(Length::Fixed(127.0)) .class(Theme::default_input()), @@ -346,7 +343,7 @@ where .shrink(padding); let content = self .content - .layout(&mut tree.children[0], renderer, &limits, None); + .layout(&mut tree.children[0], renderer, &limits); let limits2 = Limits::new(Size::new(0.0, 0.0), content.size()); let txt_size = self.size.unwrap_or_else(|| renderer.default_size().0); @@ -456,10 +453,15 @@ where let child = state.children.get_mut(0).expect("fail to get child"); let text_input = child + .children + .get_mut(0) + .expect("fail to get text input") .state .downcast_mut::>(); let modifiers = state.state.downcast_mut::(); + let current_text = self.content.text().to_string(); + let mut forward_to_text = |event, shell, child, clipboard| { self.content.on_event( child, event, content, cursor, renderer, clipboard, shell, viewport, @@ -485,39 +487,43 @@ where forward_to_text(event, shell, child, clipboard) } else if text == "\u{8}" { // Backspace - if T::zero().eq(&self.value) { - event::Status::Ignored - } else { - let mut new_val = self.value.to_string(); - match text_input.cursor().state(&Value::new(&new_val)) { - cursor::State::Index(idx) - if idx >= 1 && idx <= new_val.len() => - { - _ = new_val.remove(idx - 1); - } - cursor::State::Selection { start, end } - if start <= new_val.len() && end <= new_val.len() => - { - new_val.replace_range(start.min(end)..start.max(end), ""); - } - _ => return event::Status::Ignored, + if current_text == T::zero().to_string() { + return event::Status::Ignored; + } + let mut new_val = current_text; + match text_input.cursor().state(&Value::new(&new_val)) { + cursor::State::Index(idx) + if idx >= 1 && idx <= new_val.len() => + { + _ = new_val.remove(idx - 1); } - - if new_val.is_empty() { - new_val = T::zero().to_string(); + cursor::State::Selection { start, end } + if start <= new_val.len() && end <= new_val.len() => + { + new_val.replace_range(start.min(end)..start.max(end), ""); } + _ => return event::Status::Ignored, + } - match T::from_str(&new_val) { - Ok(val) - if (self.min..self.max).contains(&val) - && val != self.value => - { - self.value = val; - forward_to_text(event, shell, child, clipboard) - } - Ok(_) => event::Status::Captured, - _ => event::Status::Ignored, + if new_val.is_empty() { + new_val = T::zero().to_string(); + } + + match T::from_str(&new_val) { + Ok(val) + if (self.min..self.max).contains(&val) + && val != self.value => + { + self.value = val; + forward_to_text(event, shell, child, clipboard) } + Ok(val) + if (self.min..self.max).contains(&val) => + { + forward_to_text(event, shell, child, clipboard) + } + Ok(_) => event::Status::Captured, + _ => event::Status::Ignored, } } else { let input = if text == "\u{16}" { @@ -526,7 +532,7 @@ where Some(paste) => paste, None => return event::Status::Ignored, } - } else if text.parse::().is_err() && text != "-" { + } else if text.parse::().is_err() && text != "-" && text != "." { return event::Status::Ignored; } else { text.to_string() @@ -534,7 +540,7 @@ where let input = input.trim(); - let mut new_val = self.value.to_string(); + let mut new_val = current_text; match text_input.cursor().state(&Value::new(&new_val)) { cursor::State::Index(idx) if idx <= new_val.len() => { new_val.insert_str(idx, input); @@ -554,7 +560,11 @@ where self.value = val; forward_to_text(event, shell, child, clipboard) } - Ok(_) => event::Status::Captured, + Ok(val) + if (self.min..self.max).contains(&val) => + forward_to_text(event, shell, child, clipboard), + Ok(_) => + event::Status::Captured, _ => event::Status::Ignored, } } @@ -569,7 +579,9 @@ where event::Status::Captured } keyboard::Key::Named( - keyboard::key::Named::ArrowLeft | keyboard::key::Named::ArrowRight, + keyboard::key::Named::ArrowLeft | keyboard::key::Named::ArrowRight | + keyboard::key::Named::Home | + keyboard::key::Named::End, ) => forward_to_text(event, shell, child, clipboard), _ => event::Status::Ignored, }, @@ -661,7 +673,7 @@ where state: &Tree, renderer: &mut Renderer, theme: &Theme, - _style: &renderer::Style, + style: &renderer::Style, layout: Layout<'_>, cursor: Cursor, viewport: &Rectangle, @@ -684,9 +696,9 @@ where &state.children[0], renderer, theme, + style, content_layout, cursor, - None, viewport, ); let is_decrease_disabled = self.value <= self.min || self.min == self.max; diff --git a/src/widgets/typed_input.rs b/src/widgets/typed_input.rs new file mode 100644 index 00000000..570c7b08 --- /dev/null +++ b/src/widgets/typed_input.rs @@ -0,0 +1,313 @@ +//! Display fields that can only be filled with a specific type. +//! +//! *This API requires the following crate features to be activated: `typed_input`* + +use iced::mouse::{self, Cursor}; +use iced::{widget::text_input::{self, TextInput}, Size, Event, event}; +use iced::advanced::layout::{Node, Limits, Layout}; +use iced::advanced::widget::{Tree, Widget, tree::{State, Tag}, Operation}; +use iced::advanced::{Clipboard, Shell}; +use iced::{Length, Rectangle, Element}; + +use std::{ + fmt::Display, + str::FromStr, +}; + +/// The default padding +const DEFAULT_PADDING: f32 = 5.0; + +/// A field that can only be filled with a specific type. +/// +/// # Example +/// ```ignore +/// # use iced_aw::TypedInput; +/// # +/// #[derive(Debug, Clone)] +/// enum Message { +/// TypedInputChanged(u32), +/// } +/// +/// let value = 12; +/// let max = 1275; +/// +/// let input = TypedInput::new( +/// value, +/// Message::TypedInputChanged, +/// ); +/// ``` +pub struct TypedInput<'a, T, Message, Theme = iced::Theme, Renderer = iced::Renderer> +where + Renderer: iced::advanced::text::Renderer, + Theme: text_input::Catalog, +{ + /// The current value of the [`TypedInput`]. + value: T, + /// The underlying element of the [`TypeInput`]. + text_input: text_input::TextInput<'a, InternalMessage, Theme, Renderer>, + text: String, + /// The ``on_change`` event of the [`TextInput`]. + on_change: Box Message>, + /// The ``on_change`` event of the [`TextInput`]. + on_submit: Option, + /// The font text of the [`TextInput`]. + font: Renderer::Font, +} + +#[derive(Debug, Clone, PartialEq)] +enum InternalMessage { + OnChange(String), + OnSubmit, +} + +impl<'a, T, Message, Theme, Renderer> TypedInput<'a, T, Message, Theme, Renderer> +where + T: Display + FromStr, + Message: Clone, + Renderer: iced::advanced::text::Renderer, + Theme: text_input::Catalog, +{ + /// Creates a new [`TypedInput`]. + /// + /// It expects: + /// - the current value + /// - a function that produces a message when the [`TypedInput`] changes + pub fn new(value: T, on_changed: F) -> Self + where + F: 'static + Fn(T) -> Message + Copy, + T: 'a + Clone, + { + let padding = DEFAULT_PADDING; + // let move_value = value.clone(); + // let convert_to_t = move |s: String| on_changed(T::from_str(&s).unwrap_or(move_value.clone())); + + Self { + value: value.clone(), + text_input: text_input::TextInput::new("", format!("{value}").as_str()) + .on_input(InternalMessage::OnChange) + .on_submit(InternalMessage::OnSubmit) + .padding(padding) + .width(Length::Fixed(127.0)) + .class(::default()), + text: value.to_string(), + on_change: Box::new(on_changed), + on_submit: None, + font: Renderer::Font::default(), + } + } + + /// Gets the text value of the [`TypedInput`]. + pub fn text(&self) -> &str { + &self.text + } + + /// Sets the width of the [`TypedInput`]. + #[must_use] + pub fn width(mut self, width: Length) -> Self { + self.text_input = self.text_input.width(width); + self + } + + /// Sets the [`Font`] of the [`Text`]. + /// + /// [`Font`]: core::Font + /// [`Text`]: core::widget::Text + #[allow(clippy::needless_pass_by_value)] + #[must_use] + pub fn font(mut self, font: Renderer::Font) -> Self { + self.font = font; + self.text_input = self.text_input.font(font); + self + } + + /// Sets the message that should be produced when the [`TextInput`] is + /// focused and the enter key is pressed. + #[must_use] + pub fn on_submit(mut self, message: Message) -> Self { + self.on_submit = Some(message); + self + } + + /// Sets the padding of the [`TypedInput`]. + #[must_use] + pub fn padding(mut self, units: f32) -> Self { + self.text_input = self.text_input.padding(units); + self + } + + /// Sets the text size of the [`TypedInput`]. + #[must_use] + pub fn size(mut self, size: f32) -> Self { + self.text_input = self.text_input.size(size); + self + } + + /// Sets the style of the input of the [`TypedInput`]. + #[must_use] + pub fn style( + mut self, + style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a, + ) -> Self + where + ::Class<'a>: From>, + { + self.text_input = self.text_input.style(style); + self + } + + /// Sets the class of the input of the [`TypedInput`]. + #[must_use] + pub fn class( + mut self, + class: impl Into<::Class<'a>>, + ) -> Self + { + self.text_input = self.text_input.class(class); + self + } +} + +impl<'a, T, Message, Theme, Renderer> Widget + for TypedInput<'a, T, Message, Theme, Renderer> +where + T: Display + FromStr + Clone + PartialEq, + Message: 'a + Clone, + Renderer: 'a + iced::advanced::text::Renderer, + Theme: text_input::Catalog, +{ + fn tag(&self) -> Tag { + Tag::of::<()>() + } + fn state(&self) -> State { + State::new(()) + } + + fn children(&self) -> Vec { + vec![Tree { + tag: self.text_input.tag(), + state: self.text_input.state(), + children: self.text_input.children(), + }] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children_custom( + &[&self.text_input], + |state, content| content.diff(state), + |&content| Tree { + tag: content.tag(), + state: content.state(), + children: content.children(), + }, + ); + } + + fn size(&self) -> Size { + as Widget<_, _, _>>::size(&self.text_input) + } + + fn layout(&self, tree: &mut Tree,renderer: &Renderer, limits: &Limits) -> Node { + let content = as Widget<_, _, _>>::layout(&self.text_input, &mut tree.children[0], renderer, limits); + let size = limits.resolve(Length::Shrink, Length::Shrink, content.size()); + Node::with_children( + size, + vec![ content ] + ) + } + + fn draw(&self, tree: &Tree, renderer: &mut Renderer, theme: &Theme, style: &iced::advanced::renderer::Style, layout: Layout<'_>, cursor: Cursor, viewport: &Rectangle) { + let mut children = layout.children(); + let text_input_layout = children.next().expect("fail to get TextInput layout"); + as Widget<_, _, _>>::draw(&self.text_input, &tree.children[0], renderer, theme, style, text_input_layout, cursor, viewport); + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor: Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + as Widget<_, _, _>>::mouse_interaction(&self.text_input, &state.children[0], layout.children().next().expect("TypedInput inner child Textbox was not created."), cursor, viewport, renderer) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation<()>, + ) { + as Widget<_, _, _>>::operate(&self.text_input, &mut tree.children[0], layout.children().next().expect("TypedInput inner child Textbox was not created."), renderer, operation) + } + + #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] + fn on_event( + &mut self, + state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell, + viewport: &Rectangle, + ) -> event::Status { + let text_input_layout = layout.children().next().expect("fail to get text_input layout"); + + let child = &mut state.children[0]; + + let mut messages = Vec::new(); + let mut sub_shell = Shell::new(&mut messages); + let status = self.text_input.on_event( + child, event, text_input_layout, cursor, renderer, clipboard, &mut sub_shell, viewport, + ); + // todo!() + // println!("shell: {:?}", shell); + if let Some(redraw) = sub_shell.redraw_request() { + shell.request_redraw(redraw); + } + if sub_shell.is_layout_invalid() { + shell.invalidate_layout(); + } + if sub_shell.are_widgets_invalid() { + shell.invalidate_widgets(); + } + + for message in messages { + match message { + InternalMessage::OnChange(value) => { + self.text = value; + if let Ok(val) = T::from_str(&self.text) { + if self.value != val { + self.value = val.clone(); + shell.publish((self.on_change)(val)); + } + } + shell.invalidate_layout(); + } + InternalMessage::OnSubmit => { + if let Some(on_submit) = &self.on_submit { + shell.publish(on_submit.clone()); + } + } + } + } + status + } + +} + +impl<'a, T, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + T: 'a + Display + FromStr + Clone + PartialEq, + Message: 'a + Clone, + Renderer: 'a + iced::advanced::text::Renderer, + Theme: 'a + text_input::Catalog, +{ + fn from(typed_input: TypedInput<'a, T, Message, Theme, Renderer>) -> Self { + Element::new(typed_input) + } +}