diff --git a/examples/text_box.rs b/examples/text_box.rs index b4081b8b0f61386025b7ce6b2e81ce8acb4b544d..17286c14715f669adb62c0fb07095f517cb4a85b 100644 --- a/examples/text_box.rs +++ b/examples/text_box.rs @@ -11,13 +11,14 @@ use kayak_ui::core::{ styles::{Style, StyleProp, Units}, widget, }; -use kayak_ui::widgets::{App, OnChange, TextBox, Window}; +use kayak_ui::widgets::{App, Inspector, OnChange, SpinBox, SpinBoxStyle, TextBox, Window}; #[widget] fn TextBoxExample() { let (value, set_value, _) = use_state!("I started with a value!".to_string()); let (empty_value, set_empty_value, _) = use_state!("".to_string()); let (red_value, set_red_value, _) = use_state!("This text is red".to_string()); + let (spin_value, set_spin_value, _) = use_state!("3".to_string()); let input_styles = Style { top: StyleProp::Value(Units::Pixels(10.0)), @@ -41,8 +42,14 @@ fn TextBoxExample() { set_red_value(event.value); }); + let on_change_spin = OnChange::new(move |event| { + set_spin_value(event.value); + }); + + let vert = SpinBoxStyle::Vertical; + rsx! { - <Window position={(50.0, 50.0)} size={(300.0, 300.0)} title={"TextBox Example".to_string()}> + <Window position={(50.0, 50.0)} size={(500.0, 300.0)} title={"TextBox Example".to_string()}> <TextBox styles={Some(input_styles)} value={value} on_change={Some(on_change)} /> <TextBox styles={Some(input_styles)} @@ -51,6 +58,21 @@ fn TextBoxExample() { placeholder={Some("This is a placeholder".to_string())} /> <TextBox styles={Some(red_text_styles)} value={red_value} on_change={Some(on_change_red)} /> + <SpinBox + styles={Some(input_styles)} + value={spin_value} + on_change={Some(on_change_spin)} + min_val={0.0} + max_val={10.0} + /> + <SpinBox + spin_button_style={vert} + styles={Some(input_styles)} + value={spin_value} + on_change={Some(on_change_spin)} + min_val={0.0} + max_val={10.0} + /> </Window> } } diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 4073a78ccac80c0f1a9c9e4f7ff221395fb12bbd..a8c19d61c4284cae1dffa1abb6252ad3700bce06 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -8,7 +8,9 @@ mod if_element; mod image; mod inspector; mod nine_patch; +mod on_change; mod scroll; +mod spin_box; mod text; mod text_box; mod tooltip; @@ -24,7 +26,9 @@ pub use if_element::*; pub use image::*; pub use inspector::*; pub use nine_patch::*; +pub use on_change::*; pub use scroll::*; +pub use spin_box::*; pub use text::*; pub use text_box::*; pub use tooltip::*; diff --git a/src/widgets/on_change.rs b/src/widgets/on_change.rs new file mode 100644 index 0000000000000000000000000000000000000000..b4fdebba15a0ea64cf3730e6eb54f7541c9a320e --- /dev/null +++ b/src/widgets/on_change.rs @@ -0,0 +1,27 @@ +use std::sync::{Arc, RwLock}; + +#[derive(Debug, Clone, PartialEq)] +pub struct ChangeEvent { + pub value: String, +} + +#[derive(Clone)] +pub struct OnChange(pub Arc<RwLock<dyn FnMut(ChangeEvent) + Send + Sync + 'static>>); + +impl OnChange { + pub fn new<F: FnMut(ChangeEvent) + Send + Sync + 'static>(f: F) -> OnChange { + OnChange(Arc::new(RwLock::new(f))) + } +} + +impl PartialEq for OnChange { + fn eq(&self, _other: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for OnChange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("OnChange").finish() + } +} diff --git a/src/widgets/spin_box.rs b/src/widgets/spin_box.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e8317bb24180d22e96091e7e7425acff624f02c --- /dev/null +++ b/src/widgets/spin_box.rs @@ -0,0 +1,352 @@ +use std::{fmt::Debug, fmt::Formatter, sync::Arc}; + +use crate::{ + core::{ + render_command::RenderCommand, + rsx, + styles::{Corner, Style, Units}, + widget, Bound, Children, Color, EventType, MutableBound, OnEvent, WidgetProps, + }, + widgets::{Button, ChangeEvent}, +}; +use kayak_core::{ + styles::{LayoutType, StyleProp}, + CursorIcon, OnLayout, +}; +use kayak_render_macros::use_state; + +use crate::widgets::{Background, Clip, OnChange, Text}; + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum SpinBoxStyle { + Horizontal, + Vertical, +} + +impl Default for SpinBoxStyle { + fn default() -> Self { + SpinBoxStyle::Horizontal + } +} + +#[derive(Debug, PartialEq, Clone)] +pub struct SpinBoxProps { + /// If true, prevents the widget from being focused (and consequently edited) + pub disabled: bool, + /// A callback for when the text value was changed + pub on_change: Option<OnChange>, + /// The text to display when the user input is empty + pub placeholder: Option<String>, + /// Whether spinbox is horizontally or vertically aligned. + pub spin_button_style: SpinBoxStyle, + /// The user input + /// + /// This is a controlled state. You _must_ set this to the value to you wish to be displayed. + /// You can use the [`on_change`] callback to update this prop as the user types. + pub value: String, + pub styles: Option<Style>, + /// Text on increment button defaults to `>` + pub incr_str: String, + /// Text on decrement button defaults to `<` + pub decr_str: String, + /// Events on increment button press + pub on_incr_event: Option<OnEvent>, + /// Events on decrement button press + pub on_decr_event: Option<OnEvent>, + /// Events for text edit + pub on_event: Option<OnEvent>, + /// Minimal value + pub min_val: f32, + /// Maximal value + pub max_val: f32, + pub children: Option<Children>, + pub on_layout: Option<OnLayout>, + pub focusable: Option<bool>, +} + +impl SpinBoxProps { + pub fn get_float(&self) -> f32 { + self.value.parse::<f32>().unwrap_or_default() + } + + pub fn get_int(&self) -> i16 { + let temp_float = self.get_float(); + if temp_float > f32::from(i16::MAX) { + i16::MAX + } else if temp_float < f32::from(i16::MIN) { + i16::MIN + } else { + temp_float.round() as i16 + } + } +} + +impl Default for SpinBoxProps { + fn default() -> SpinBoxProps { + SpinBoxProps { + incr_str: "+".into(), + decr_str: "-".into(), + disabled: Default::default(), + on_change: Default::default(), + placeholder: Default::default(), + value: Default::default(), + styles: Default::default(), + spin_button_style: Default::default(), + children: Default::default(), + on_incr_event: Default::default(), + on_decr_event: Default::default(), + on_event: Default::default(), + min_val: f32::MIN, + max_val: f32::MAX, + on_layout: Default::default(), + focusable: Default::default(), + } + } +} + +impl WidgetProps for SpinBoxProps { + fn get_children(&self) -> Option<Children> { + self.children.clone() + } + + fn set_children(&mut self, children: Option<Children>) { + self.children = children; + } + + fn get_styles(&self) -> Option<Style> { + self.styles.clone() + } + + fn get_on_event(&self) -> Option<OnEvent> { + self.on_event.clone() + } + + fn get_on_layout(&self) -> Option<OnLayout> { + self.on_layout.clone() + } + + fn get_focusable(&self) -> Option<bool> { + Some(!self.disabled) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FocusSpinbox(pub bool); + +#[widget] +/// A widget that displays a spinnable text field +/// +/// # Props +/// +/// __Type:__ [`SpinBoxProps`] +/// +/// | Common Prop | Accepted | +/// | :---------: | :------: | +/// | `children` | ✅ | +/// | `styles` | ✅ | +/// | `on_event` | ✅ | +/// | `on_layout` | ✅ | +/// | `focusable` | ✅ | +/// +pub fn SpinBox(props: SpinBoxProps) { + let SpinBoxProps { + on_change, + placeholder, + value, + max_val, + min_val, + spin_button_style, + .. + } = props.clone(); + + props.styles = Some( + Style::default() + // Required styles + .with_style(Style { + render_command: RenderCommand::Layout.into(), + ..Default::default() + }) + // Apply any prop-given styles + .with_style(&props.styles) + // If not set by props, apply these styles + .with_style(Style { + top: Units::Pixels(0.0).into(), + bottom: Units::Pixels(0.0).into(), + height: Units::Pixels(26.0).into(), + cursor: CursorIcon::Text.into(), + ..Default::default() + }), + ); + + let background_styles = Style { + background_color: Color::new(0.176, 0.196, 0.215, 1.0).into(), + border_radius: Corner::all(5.0).into(), + height: Units::Pixels(26.0).into(), + padding_left: Units::Pixels(5.0).into(), + padding_right: Units::Pixels(5.0).into(), + ..Default::default() + }; + + let has_focus = context.create_state(FocusSpinbox(false)).unwrap(); + + let mut current_value = value.clone(); + let cloned_on_change = on_change.clone(); + let cloned_has_focus = has_focus.clone(); + + props.on_event = Some(OnEvent::new(move |_, event| match event.event_type { + EventType::CharInput { c } => { + if !cloned_has_focus.get().0 { + return; + } + if is_backspace(c) { + if !current_value.is_empty() { + current_value.truncate(current_value.len() - 1); + } + } else if !c.is_control() { + current_value.push(c); + } + if let Some(on_change) = cloned_on_change.as_ref() { + if let Ok(mut on_change) = on_change.0.write() { + on_change(ChangeEvent { + value: current_value.clone(), + }); + } + } + } + EventType::Focus => cloned_has_focus.set(FocusSpinbox(true)), + EventType::Blur => cloned_has_focus.set(FocusSpinbox(false)), + _ => {} + })); + + let text_styles = if value.is_empty() || (has_focus.get().0 && value.is_empty()) { + Style { + color: Color::new(0.5, 0.5, 0.5, 1.0).into(), + ..Style::default() + } + } else { + Style { + width: Units::Stretch(100.0).into(), + ..Style::default() + } + }; + + let button_style = match spin_button_style { + SpinBoxStyle::Horizontal => Some(Style { + height: Units::Pixels(24.0).into(), + width: Units::Pixels(24.0).into(), + ..Default::default() + }), + SpinBoxStyle::Vertical => Some(Style { + height: Units::Pixels(12.0).into(), + width: Units::Pixels(24.0).into(), + + ..Default::default() + }), + }; + + let value = if value.is_empty() { + placeholder.unwrap_or_else(|| value.clone()) + } else { + value + }; + + let row = Style { + layout_type: StyleProp::Value(LayoutType::Row), + ..Style::default() + }; + + let col = Style { + layout_type: StyleProp::Value(LayoutType::Column), + height: Units::Stretch(100.0).into(), + width: Units::Pixels(26.0).into(), + ..Style::default() + }; + + let incr_str = props.clone().incr_str; + let decr_str = props.clone().decr_str; + + let (spin_value, set_val, _) = use_state!(value); + let x = spin_value.parse::<f32>().unwrap_or_default(); + let decr_fn = set_val.clone(); + let incr_fn = set_val.clone(); + + let incr_event = if let Some(event) = props.clone().on_incr_event { + event + } else { + OnEvent::new(move |_, event| match event.event_type { + EventType::Click(_) => { + if x >= max_val { + return; + } + incr_fn((x + 1.0f32).to_string()); + } + _ => {} + }) + }; + + let decr_event = if let Some(event) = props.clone().on_decr_event { + event + } else { + OnEvent::new(move |_, event| match event.event_type { + EventType::Click(_) => { + if x <= min_val { + return; + } + decr_fn((x - 1.0f32).to_string()); + } + _ => {} + }) + }; + + match spin_button_style { + SpinBoxStyle::Horizontal => { + rsx! { + <Background styles={Some(background_styles)}> + <Clip styles={Some(row)}> + <Button styles={button_style} on_event={Some(decr_event)}> + <Text content={decr_str} /> + </Button> + <Text + content={spin_value} + size={14.0} + styles={Some(text_styles)} + /> + <Button styles={button_style} on_event={Some(incr_event)}> + <Text content={incr_str} /> + </Button> + </Clip> + </Background> + } + } + SpinBoxStyle::Vertical => { + rsx! { + <Background styles={Some(background_styles)}> + + <Clip styles={Some(row)}> + <Text + content={spin_value} + size={14.0} + styles={Some(text_styles)} + /> + <Clip styles={Some(col)}> + + <Button styles={button_style} on_event={Some(incr_event)}> + <Text content={incr_str} size={11.0} /> + </Button> + <Button styles={button_style} on_event={Some(decr_event)}> + <Text content={decr_str} size={11.0}/> + </Button> + </Clip> + </Clip> + </Background> + } + } + } +} + +/// Checks if the given character contains the "Backspace" sequence +/// +/// Context: [Wikipedia](https://en.wikipedia.org/wiki/Backspace#Common_use) +fn is_backspace(c: char) -> bool { + c == '\u{8}' || c == '\u{7f}' +} diff --git a/src/widgets/text_box.rs b/src/widgets/text_box.rs index f872d2ec0318624e27c48225a2951f4ca5f82fe6..e9cfc8ad5e4d88e315f0da6d7ff5de0c2424002c 100644 --- a/src/widgets/text_box.rs +++ b/src/widgets/text_box.rs @@ -1,13 +1,15 @@ -use crate::core::{ - render_command::RenderCommand, - rsx, - styles::{Corner, Style, Units}, - widget, Bound, Children, Color, EventType, MutableBound, OnEvent, WidgetProps, +use crate::{ + core::{ + render_command::RenderCommand, + rsx, + styles::{Corner, Style, Units}, + widget, Bound, Children, Color, EventType, MutableBound, OnEvent, WidgetProps, + }, + widgets::ChangeEvent, }; use kayak_core::{CursorIcon, OnLayout}; -use std::sync::{Arc, RwLock}; -use crate::widgets::{Background, Clip, Text}; +use crate::widgets::{Background, Clip, OnChange, Text}; /// Props used by the [`TextBox`] widget #[derive(Default, Debug, PartialEq, Clone)] @@ -56,32 +58,6 @@ impl WidgetProps for TextBoxProps { } } -#[derive(Debug, Clone, PartialEq)] -pub struct ChangeEvent { - pub value: String, -} - -#[derive(Clone)] -pub struct OnChange(pub Arc<RwLock<dyn FnMut(ChangeEvent) + Send + Sync + 'static>>); - -impl OnChange { - pub fn new<F: FnMut(ChangeEvent) + Send + Sync + 'static>(f: F) -> OnChange { - OnChange(Arc::new(RwLock::new(f))) - } -} - -impl PartialEq for OnChange { - fn eq(&self, _other: &Self) -> bool { - true - } -} - -impl std::fmt::Debug for OnChange { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("OnChange").finish() - } -} - #[derive(Debug, Clone, Copy, PartialEq)] pub struct Focus(pub bool);