Skip to content

Commit

Permalink
feat: add button component
Browse files Browse the repository at this point in the history
  • Loading branch information
ccbrown committed Oct 4, 2024
1 parent c1f37e7 commit 7f1e13e
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 57 deletions.
98 changes: 44 additions & 54 deletions examples/calculator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,52 +131,42 @@ fn Screen(hooks: Hooks, props: &ScreenProps) -> impl Into<AnyElement<'static>> {
}

#[derive(Default, Props)]
struct ButtonProps {
struct CalculatorButtonProps {
label: String,
style: Option<ButtonStyle>,
on_click: Handler<'static, ()>,
}

#[component]
fn Button(props: &mut ButtonProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
fn CalculatorButton(props: &mut CalculatorButtonProps) -> impl Into<AnyElement<'static>> {
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,
)
}
}
}
}
Expand Down Expand Up @@ -313,34 +303,34 @@ fn Calculator(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
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())
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/iocraft-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -352,6 +353,7 @@ impl ToTokens for ParsedComponent {
.unwrap_or_else(|| quote!(::iocraft::NoProps));

tokens.extend(quote! {
#(#attrs)*
#vis struct #name;

impl #name {
Expand Down
126 changes: 126 additions & 0 deletions packages/iocraft/src/components/button.rs
Original file line number Diff line number Diff line change
@@ -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<AnyElement<'a>>,

/// 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<AnyElement<'static>> {
/// 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<AnyElement<'a>> {
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<AnyElement<'static>> {
let mut system = hooks.use_context_mut::<SystemContext>();
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::<Vec<_>>()
.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::<Vec<_>>()
.await;
let expected = vec!["Exit\n"];
assert_eq!(actual, expected);
}
}
3 changes: 3 additions & 0 deletions packages/iocraft/src/components/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
mod r#box;
pub use r#box::*;

mod button;
pub use button::*;

mod context_provider;
pub use context_provider::*;

Expand Down
3 changes: 1 addition & 2 deletions packages/iocraft/src/components/text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use crate::{
};
use futures::stream::Stream;
use std::{
mem,
pin::{pin, Pin},
task::{Context, Poll},
};
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions packages/iocraft/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down
10 changes: 9 additions & 1 deletion packages/iocraft/src/handler.rs
Original file line number Diff line number Diff line change
@@ -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.
///
Expand All @@ -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> {
Expand Down

0 comments on commit 7f1e13e

Please sign in to comment.