diff --git a/examples/calculator.rs b/examples/calculator.rs index 7031fc3..a8e5646 100644 --- a/examples/calculator.rs +++ b/examples/calculator.rs @@ -131,52 +131,42 @@ fn Screen(hooks: Hooks, props: &ScreenProps) -> impl Into> { } #[derive(Default, Props)] -struct ButtonProps { +struct CalculatorButtonProps { label: String, style: Option, on_click: Handler<'static, ()>, } #[component] -fn Button(props: &mut ButtonProps, mut hooks: Hooks) -> impl Into> { +fn CalculatorButton(props: &mut CalculatorButtonProps) -> impl Into> { let style = props.style.unwrap(); - hooks.use_local_terminal_events({ - let mut on_click = std::mem::take(&mut props.on_click); - move |event| match event { - TerminalEvent::FullscreenMouse(FullscreenMouseEvent { kind, .. }) - if matches!(kind, MouseEventKind::Down(_)) => - { - on_click(()); - } - _ => {} - } - }); - element! { - Box( - border_style: BorderStyle::Custom(BorderCharacters { - top: '▁', - ..Default::default() - }), - border_edges: Edges::Top, - border_color: style.trim_color, - flex_grow: 1.0, - margin_left: 1, - margin_right: 1, - ) { + Button(handler: props.on_click.take()) { Box( - background_color: style.color, - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - height: 3, + border_style: BorderStyle::Custom(BorderCharacters { + top: '▁', + ..Default::default() + }), + border_edges: Edges::Top, + border_color: style.trim_color, flex_grow: 1.0, + margin_left: 1, + margin_right: 1, ) { - Text( - content: &props.label, - color: style.text_color, - weight: Weight::Bold, - ) + Box( + background_color: style.color, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + height: 3, + flex_grow: 1.0, + ) { + Text( + content: &props.label, + color: style.text_color, + weight: Weight::Bold, + ) + } } } } @@ -313,34 +303,34 @@ fn Calculator(mut hooks: Hooks) -> impl Into> { Screen(content: expr.to_string()) } Box(width: 100pct) { - Button(label: "←", style: fn_button_style, on_click: move |_| handle_backspace()) - Button(label: "±", style: fn_button_style, on_click: move |_| handle_plus_minus()) - Button(label: "%", style: fn_button_style, on_click: move |_| handle_percent()) - Button(label: "÷", style: operator_button_style, on_click: move |_| handle_operator('÷')) + CalculatorButton(label: "←", style: fn_button_style, on_click: move |_| handle_backspace()) + CalculatorButton(label: "±", style: fn_button_style, on_click: move |_| handle_plus_minus()) + CalculatorButton(label: "%", style: fn_button_style, on_click: move |_| handle_percent()) + CalculatorButton(label: "÷", style: operator_button_style, on_click: move |_| handle_operator('÷')) } Box(width: 100pct) { - Button(label: "7", style: numpad_button_style, on_click: move |_| handle_number(7)) - Button(label: "8", style: numpad_button_style, on_click: move |_| handle_number(8)) - Button(label: "9", style: numpad_button_style, on_click: move |_| handle_number(9)) - Button(label: "×", style: operator_button_style, on_click: move |_| handle_operator('×')) + CalculatorButton(label: "7", style: numpad_button_style, on_click: move |_| handle_number(7)) + CalculatorButton(label: "8", style: numpad_button_style, on_click: move |_| handle_number(8)) + CalculatorButton(label: "9", style: numpad_button_style, on_click: move |_| handle_number(9)) + CalculatorButton(label: "×", style: operator_button_style, on_click: move |_| handle_operator('×')) } Box(width: 100pct) { - Button(label: "4", style: numpad_button_style, on_click: move |_| handle_number(4)) - Button(label: "5", style: numpad_button_style, on_click: move |_| handle_number(5)) - Button(label: "6", style: numpad_button_style, on_click: move |_| handle_number(6)) - Button(label: "-", style: operator_button_style, on_click: move |_| handle_operator('-')) + CalculatorButton(label: "4", style: numpad_button_style, on_click: move |_| handle_number(4)) + CalculatorButton(label: "5", style: numpad_button_style, on_click: move |_| handle_number(5)) + CalculatorButton(label: "6", style: numpad_button_style, on_click: move |_| handle_number(6)) + CalculatorButton(label: "-", style: operator_button_style, on_click: move |_| handle_operator('-')) } Box(width: 100pct) { - Button(label: "1", style: numpad_button_style, on_click: move |_| handle_number(1)) - Button(label: "2", style: numpad_button_style, on_click: move |_| handle_number(2)) - Button(label: "3", style: numpad_button_style, on_click: move |_| handle_number(3)) - Button(label: "+", style: operator_button_style, on_click: move |_| handle_operator('+')) + CalculatorButton(label: "1", style: numpad_button_style, on_click: move |_| handle_number(1)) + CalculatorButton(label: "2", style: numpad_button_style, on_click: move |_| handle_number(2)) + CalculatorButton(label: "3", style: numpad_button_style, on_click: move |_| handle_number(3)) + CalculatorButton(label: "+", style: operator_button_style, on_click: move |_| handle_operator('+')) } Box(width: 100pct) { - Button(label: "C", style: theme.clear_button_style(), on_click: move |_| handle_clear()) - Button(label: "0", style: numpad_button_style, on_click: move |_| handle_number(0)) - Button(label: ".", style: numpad_button_style, on_click: move |_| handle_decimal()) - Button(label: "=", style: operator_button_style, on_click: move |_| handle_equals()) + CalculatorButton(label: "C", style: theme.clear_button_style(), on_click: move |_| handle_clear()) + CalculatorButton(label: "0", style: numpad_button_style, on_click: move |_| handle_number(0)) + CalculatorButton(label: ".", style: numpad_button_style, on_click: move |_| handle_decimal()) + CalculatorButton(label: "=", style: operator_button_style, on_click: move |_| handle_equals()) } } } diff --git a/packages/iocraft-macros/src/lib.rs b/packages/iocraft-macros/src/lib.rs index 998ec62..b51004d 100644 --- a/packages/iocraft-macros/src/lib.rs +++ b/packages/iocraft-macros/src/lib.rs @@ -337,6 +337,7 @@ impl Parse for ParsedComponent { impl ToTokens for ParsedComponent { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let attrs = &self.f.attrs; let vis = &self.f.vis; let name = &self.f.sig.ident; let args = &self.f.sig.inputs; @@ -352,6 +353,7 @@ impl ToTokens for ParsedComponent { .unwrap_or_else(|| quote!(::iocraft::NoProps)); tokens.extend(quote! { + #(#attrs)* #vis struct #name; impl #name { diff --git a/packages/iocraft/src/components/button.rs b/packages/iocraft/src/components/button.rs new file mode 100644 index 0000000..9eaa99e --- /dev/null +++ b/packages/iocraft/src/components/button.rs @@ -0,0 +1,126 @@ +use crate::{ + component, components::Box, element, hooks::UseTerminalEvents, AnyElement, + FullscreenMouseEvent, Handler, Hooks, KeyCode, KeyEvent, KeyEventKind, MouseEventKind, Props, + TerminalEvent, +}; + +/// The props which can be passed to the [`Button`] component. +#[non_exhaustive] +#[derive(Default, Props)] +pub struct ButtonProps<'a> { + /// The children of the component. Exactly one child is expected. + pub children: Vec>, + + /// The handler to invoke when the button is triggered. + /// + /// The button can be triggered two ways: + /// + /// - By clicking on it with the mouse while in fullscreen mode. + /// - By pressing the Enter or Space key while [`has_focus`](Self::has_focus) is `true`. + pub handler: Handler<'static, ()>, + + /// True if the button has focus and should process keyboard input. + pub has_focus: bool, +} + +/// `Button` is a component that invokes a handler when clicked or when the Enter or Space key is pressed while it has focus. +/// +/// # Example +/// +/// ``` +/// # use iocraft::prelude::*; +/// # fn foo() -> impl Into> { +/// element! { +/// Button(handler: |_| { /* do something */ }, has_focus: true) { +/// Box(border_style: BorderStyle::Round, border_color: Color::Blue) { +/// Text(content: "Click me!") +/// } +/// } +/// } +/// # } +/// ``` +#[component] +pub fn Button<'a>(mut hooks: Hooks, props: &mut ButtonProps<'a>) -> impl Into> { + hooks.use_local_terminal_events({ + let mut handler = props.handler.take(); + let has_focus = props.has_focus; + move |event| match event { + TerminalEvent::FullscreenMouse(FullscreenMouseEvent { + kind: MouseEventKind::Down(_), + .. + }) => { + handler(()); + } + TerminalEvent::Key(KeyEvent { code, kind, .. }) + if has_focus + && kind != KeyEventKind::Release + && (code == KeyCode::Enter || code == KeyCode::Char(' ')) => + { + handler(()); + } + _ => {} + } + }); + + match props.children.iter_mut().next() { + Some(child) => child.into(), + None => element!(Box).into_any(), + } +} + +#[cfg(test)] +mod tests { + use crate::prelude::*; + use crossterm::event::MouseButton; + use futures::stream::StreamExt; + use macro_rules_attribute::apply; + use smol_macros::test; + + #[component] + fn MyComponent(mut hooks: Hooks) -> impl Into> { + let mut system = hooks.use_context_mut::(); + let mut should_exit = hooks.use_state(|| false); + + if should_exit.get() { + system.exit(); + } + + element! { + Button(handler: move |_| should_exit.set(true), has_focus: true) { + Text(content: "Exit") + } + } + } + + #[apply(test!)] + async fn test_button_click() { + let actual = element!(MyComponent) + .mock_terminal_render_loop(MockTerminalConfig::with_events(futures::stream::once( + async { + TerminalEvent::FullscreenMouse(FullscreenMouseEvent::new( + MouseEventKind::Down(MouseButton::Left), + 2, + 0, + )) + }, + ))) + .map(|c| c.to_string()) + .collect::>() + .await; + let expected = vec!["Exit\n"]; + assert_eq!(actual, expected); + } + + #[apply(test!)] + async fn test_button_key_input() { + let actual = element!(MyComponent) + .mock_terminal_render_loop(MockTerminalConfig::with_events(futures::stream::once( + async { TerminalEvent::Key(KeyEvent::new(KeyEventKind::Press, KeyCode::Enter)) }, + ))) + .map(|c| c.to_string()) + .collect::>() + .await; + let expected = vec!["Exit\n"]; + assert_eq!(actual, expected); + } +} diff --git a/packages/iocraft/src/components/mod.rs b/packages/iocraft/src/components/mod.rs index 615affb..657e00c 100644 --- a/packages/iocraft/src/components/mod.rs +++ b/packages/iocraft/src/components/mod.rs @@ -1,6 +1,9 @@ mod r#box; pub use r#box::*; +mod button; +pub use button::*; + mod context_provider; pub use context_provider::*; diff --git a/packages/iocraft/src/components/text_input.rs b/packages/iocraft/src/components/text_input.rs index d89e68b..8930634 100644 --- a/packages/iocraft/src/components/text_input.rs +++ b/packages/iocraft/src/components/text_input.rs @@ -4,7 +4,6 @@ use crate::{ }; use futures::stream::Stream; use std::{ - mem, pin::{pin, Pin}, task::{Context, Poll}, }; @@ -92,7 +91,7 @@ impl Component for TextInput { ..Default::default() }; self.value = props.value.clone(); - self.handler = mem::take(&mut props.on_change); + self.handler = props.on_change.take(); self.has_focus = props.has_focus; updater.set_layout_style(taffy::style::Style { size: taffy::Size::percent(1.0), diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs index 1d7cd01..0106948 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -131,6 +131,16 @@ where } } +impl<'a, 'b: 'a> From<&'a mut AnyElement<'b>> for AnyElement<'b> { + fn from(e: &'a mut AnyElement<'b>) -> Self { + Self { + key: e.key.clone(), + props: e.props.borrow(), + helper: e.helper.copy(), + } + } +} + mod private { use super::*; diff --git a/packages/iocraft/src/handler.rs b/packages/iocraft/src/handler.rs index 86bc895..7554ffb 100644 --- a/packages/iocraft/src/handler.rs +++ b/packages/iocraft/src/handler.rs @@ -1,4 +1,7 @@ -use std::ops::{Deref, DerefMut}; +use std::{ + mem, + ops::{Deref, DerefMut}, +}; /// `Handler` is a type representing an optional event handler, commonly used for component properties. /// @@ -11,6 +14,11 @@ impl<'a, T> Handler<'a, T> { pub fn is_default(&self) -> bool { !self.0 } + + /// Takes the handler, leaving a default-initialized handler in its place. + pub fn take(&mut self) -> Self { + mem::take(self) + } } impl<'a, T> Default for Handler<'a, T> {