Skip to content

Commit 7f1e13e

Browse files
committed
feat: add button component
1 parent c1f37e7 commit 7f1e13e

File tree

7 files changed

+195
-57
lines changed

7 files changed

+195
-57
lines changed

examples/calculator.rs

Lines changed: 44 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -131,52 +131,42 @@ fn Screen(hooks: Hooks, props: &ScreenProps) -> impl Into<AnyElement<'static>> {
131131
}
132132

133133
#[derive(Default, Props)]
134-
struct ButtonProps {
134+
struct CalculatorButtonProps {
135135
label: String,
136136
style: Option<ButtonStyle>,
137137
on_click: Handler<'static, ()>,
138138
}
139139

140140
#[component]
141-
fn Button(props: &mut ButtonProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
141+
fn CalculatorButton(props: &mut CalculatorButtonProps) -> impl Into<AnyElement<'static>> {
142142
let style = props.style.unwrap();
143143

144-
hooks.use_local_terminal_events({
145-
let mut on_click = std::mem::take(&mut props.on_click);
146-
move |event| match event {
147-
TerminalEvent::FullscreenMouse(FullscreenMouseEvent { kind, .. })
148-
if matches!(kind, MouseEventKind::Down(_)) =>
149-
{
150-
on_click(());
151-
}
152-
_ => {}
153-
}
154-
});
155-
156144
element! {
157-
Box(
158-
border_style: BorderStyle::Custom(BorderCharacters {
159-
top: '▁',
160-
..Default::default()
161-
}),
162-
border_edges: Edges::Top,
163-
border_color: style.trim_color,
164-
flex_grow: 1.0,
165-
margin_left: 1,
166-
margin_right: 1,
167-
) {
145+
Button(handler: props.on_click.take()) {
168146
Box(
169-
background_color: style.color,
170-
justify_content: JustifyContent::Center,
171-
align_items: AlignItems::Center,
172-
height: 3,
147+
border_style: BorderStyle::Custom(BorderCharacters {
148+
top: '▁',
149+
..Default::default()
150+
}),
151+
border_edges: Edges::Top,
152+
border_color: style.trim_color,
173153
flex_grow: 1.0,
154+
margin_left: 1,
155+
margin_right: 1,
174156
) {
175-
Text(
176-
content: &props.label,
177-
color: style.text_color,
178-
weight: Weight::Bold,
179-
)
157+
Box(
158+
background_color: style.color,
159+
justify_content: JustifyContent::Center,
160+
align_items: AlignItems::Center,
161+
height: 3,
162+
flex_grow: 1.0,
163+
) {
164+
Text(
165+
content: &props.label,
166+
color: style.text_color,
167+
weight: Weight::Bold,
168+
)
169+
}
180170
}
181171
}
182172
}
@@ -313,34 +303,34 @@ fn Calculator(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
313303
Screen(content: expr.to_string())
314304
}
315305
Box(width: 100pct) {
316-
Button(label: "←", style: fn_button_style, on_click: move |_| handle_backspace())
317-
Button(label: "±", style: fn_button_style, on_click: move |_| handle_plus_minus())
318-
Button(label: "%", style: fn_button_style, on_click: move |_| handle_percent())
319-
Button(label: "÷", style: operator_button_style, on_click: move |_| handle_operator('÷'))
306+
CalculatorButton(label: "←", style: fn_button_style, on_click: move |_| handle_backspace())
307+
CalculatorButton(label: "±", style: fn_button_style, on_click: move |_| handle_plus_minus())
308+
CalculatorButton(label: "%", style: fn_button_style, on_click: move |_| handle_percent())
309+
CalculatorButton(label: "÷", style: operator_button_style, on_click: move |_| handle_operator('÷'))
320310
}
321311
Box(width: 100pct) {
322-
Button(label: "7", style: numpad_button_style, on_click: move |_| handle_number(7))
323-
Button(label: "8", style: numpad_button_style, on_click: move |_| handle_number(8))
324-
Button(label: "9", style: numpad_button_style, on_click: move |_| handle_number(9))
325-
Button(label: "×", style: operator_button_style, on_click: move |_| handle_operator('×'))
312+
CalculatorButton(label: "7", style: numpad_button_style, on_click: move |_| handle_number(7))
313+
CalculatorButton(label: "8", style: numpad_button_style, on_click: move |_| handle_number(8))
314+
CalculatorButton(label: "9", style: numpad_button_style, on_click: move |_| handle_number(9))
315+
CalculatorButton(label: "×", style: operator_button_style, on_click: move |_| handle_operator('×'))
326316
}
327317
Box(width: 100pct) {
328-
Button(label: "4", style: numpad_button_style, on_click: move |_| handle_number(4))
329-
Button(label: "5", style: numpad_button_style, on_click: move |_| handle_number(5))
330-
Button(label: "6", style: numpad_button_style, on_click: move |_| handle_number(6))
331-
Button(label: "-", style: operator_button_style, on_click: move |_| handle_operator('-'))
318+
CalculatorButton(label: "4", style: numpad_button_style, on_click: move |_| handle_number(4))
319+
CalculatorButton(label: "5", style: numpad_button_style, on_click: move |_| handle_number(5))
320+
CalculatorButton(label: "6", style: numpad_button_style, on_click: move |_| handle_number(6))
321+
CalculatorButton(label: "-", style: operator_button_style, on_click: move |_| handle_operator('-'))
332322
}
333323
Box(width: 100pct) {
334-
Button(label: "1", style: numpad_button_style, on_click: move |_| handle_number(1))
335-
Button(label: "2", style: numpad_button_style, on_click: move |_| handle_number(2))
336-
Button(label: "3", style: numpad_button_style, on_click: move |_| handle_number(3))
337-
Button(label: "+", style: operator_button_style, on_click: move |_| handle_operator('+'))
324+
CalculatorButton(label: "1", style: numpad_button_style, on_click: move |_| handle_number(1))
325+
CalculatorButton(label: "2", style: numpad_button_style, on_click: move |_| handle_number(2))
326+
CalculatorButton(label: "3", style: numpad_button_style, on_click: move |_| handle_number(3))
327+
CalculatorButton(label: "+", style: operator_button_style, on_click: move |_| handle_operator('+'))
338328
}
339329
Box(width: 100pct) {
340-
Button(label: "C", style: theme.clear_button_style(), on_click: move |_| handle_clear())
341-
Button(label: "0", style: numpad_button_style, on_click: move |_| handle_number(0))
342-
Button(label: ".", style: numpad_button_style, on_click: move |_| handle_decimal())
343-
Button(label: "=", style: operator_button_style, on_click: move |_| handle_equals())
330+
CalculatorButton(label: "C", style: theme.clear_button_style(), on_click: move |_| handle_clear())
331+
CalculatorButton(label: "0", style: numpad_button_style, on_click: move |_| handle_number(0))
332+
CalculatorButton(label: ".", style: numpad_button_style, on_click: move |_| handle_decimal())
333+
CalculatorButton(label: "=", style: operator_button_style, on_click: move |_| handle_equals())
344334
}
345335
}
346336
}

packages/iocraft-macros/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ impl Parse for ParsedComponent {
337337

338338
impl ToTokens for ParsedComponent {
339339
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
340+
let attrs = &self.f.attrs;
340341
let vis = &self.f.vis;
341342
let name = &self.f.sig.ident;
342343
let args = &self.f.sig.inputs;
@@ -352,6 +353,7 @@ impl ToTokens for ParsedComponent {
352353
.unwrap_or_else(|| quote!(::iocraft::NoProps));
353354

354355
tokens.extend(quote! {
356+
#(#attrs)*
355357
#vis struct #name;
356358

357359
impl #name {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
use crate::{
2+
component, components::Box, element, hooks::UseTerminalEvents, AnyElement,
3+
FullscreenMouseEvent, Handler, Hooks, KeyCode, KeyEvent, KeyEventKind, MouseEventKind, Props,
4+
TerminalEvent,
5+
};
6+
7+
/// The props which can be passed to the [`Button`] component.
8+
#[non_exhaustive]
9+
#[derive(Default, Props)]
10+
pub struct ButtonProps<'a> {
11+
/// The children of the component. Exactly one child is expected.
12+
pub children: Vec<AnyElement<'a>>,
13+
14+
/// The handler to invoke when the button is triggered.
15+
///
16+
/// The button can be triggered two ways:
17+
///
18+
/// - By clicking on it with the mouse while in fullscreen mode.
19+
/// - By pressing the Enter or Space key while [`has_focus`](Self::has_focus) is `true`.
20+
pub handler: Handler<'static, ()>,
21+
22+
/// True if the button has focus and should process keyboard input.
23+
pub has_focus: bool,
24+
}
25+
26+
/// `Button` is a component that invokes a handler when clicked or when the Enter or Space key is pressed while it has focus.
27+
///
28+
/// # Example
29+
///
30+
/// ```
31+
/// # use iocraft::prelude::*;
32+
/// # fn foo() -> impl Into<AnyElement<'static>> {
33+
/// element! {
34+
/// Button(handler: |_| { /* do something */ }, has_focus: true) {
35+
/// Box(border_style: BorderStyle::Round, border_color: Color::Blue) {
36+
/// Text(content: "Click me!")
37+
/// }
38+
/// }
39+
/// }
40+
/// # }
41+
/// ```
42+
#[component]
43+
pub fn Button<'a>(mut hooks: Hooks, props: &mut ButtonProps<'a>) -> impl Into<AnyElement<'a>> {
44+
hooks.use_local_terminal_events({
45+
let mut handler = props.handler.take();
46+
let has_focus = props.has_focus;
47+
move |event| match event {
48+
TerminalEvent::FullscreenMouse(FullscreenMouseEvent {
49+
kind: MouseEventKind::Down(_),
50+
..
51+
}) => {
52+
handler(());
53+
}
54+
TerminalEvent::Key(KeyEvent { code, kind, .. })
55+
if has_focus
56+
&& kind != KeyEventKind::Release
57+
&& (code == KeyCode::Enter || code == KeyCode::Char(' ')) =>
58+
{
59+
handler(());
60+
}
61+
_ => {}
62+
}
63+
});
64+
65+
match props.children.iter_mut().next() {
66+
Some(child) => child.into(),
67+
None => element!(Box).into_any(),
68+
}
69+
}
70+
71+
#[cfg(test)]
72+
mod tests {
73+
use crate::prelude::*;
74+
use crossterm::event::MouseButton;
75+
use futures::stream::StreamExt;
76+
use macro_rules_attribute::apply;
77+
use smol_macros::test;
78+
79+
#[component]
80+
fn MyComponent(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
81+
let mut system = hooks.use_context_mut::<SystemContext>();
82+
let mut should_exit = hooks.use_state(|| false);
83+
84+
if should_exit.get() {
85+
system.exit();
86+
}
87+
88+
element! {
89+
Button(handler: move |_| should_exit.set(true), has_focus: true) {
90+
Text(content: "Exit")
91+
}
92+
}
93+
}
94+
95+
#[apply(test!)]
96+
async fn test_button_click() {
97+
let actual = element!(MyComponent)
98+
.mock_terminal_render_loop(MockTerminalConfig::with_events(futures::stream::once(
99+
async {
100+
TerminalEvent::FullscreenMouse(FullscreenMouseEvent::new(
101+
MouseEventKind::Down(MouseButton::Left),
102+
2,
103+
0,
104+
))
105+
},
106+
)))
107+
.map(|c| c.to_string())
108+
.collect::<Vec<_>>()
109+
.await;
110+
let expected = vec!["Exit\n"];
111+
assert_eq!(actual, expected);
112+
}
113+
114+
#[apply(test!)]
115+
async fn test_button_key_input() {
116+
let actual = element!(MyComponent)
117+
.mock_terminal_render_loop(MockTerminalConfig::with_events(futures::stream::once(
118+
async { TerminalEvent::Key(KeyEvent::new(KeyEventKind::Press, KeyCode::Enter)) },
119+
)))
120+
.map(|c| c.to_string())
121+
.collect::<Vec<_>>()
122+
.await;
123+
let expected = vec!["Exit\n"];
124+
assert_eq!(actual, expected);
125+
}
126+
}

packages/iocraft/src/components/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
mod r#box;
22
pub use r#box::*;
33

4+
mod button;
5+
pub use button::*;
6+
47
mod context_provider;
58
pub use context_provider::*;
69

packages/iocraft/src/components/text_input.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ use crate::{
44
};
55
use futures::stream::Stream;
66
use std::{
7-
mem,
87
pin::{pin, Pin},
98
task::{Context, Poll},
109
};
@@ -92,7 +91,7 @@ impl Component for TextInput {
9291
..Default::default()
9392
};
9493
self.value = props.value.clone();
95-
self.handler = mem::take(&mut props.on_change);
94+
self.handler = props.on_change.take();
9695
self.has_focus = props.has_focus;
9796
updater.set_layout_style(taffy::style::Style {
9897
size: taffy::Size::percent(1.0),

packages/iocraft/src/element.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ where
131131
}
132132
}
133133

134+
impl<'a, 'b: 'a> From<&'a mut AnyElement<'b>> for AnyElement<'b> {
135+
fn from(e: &'a mut AnyElement<'b>) -> Self {
136+
Self {
137+
key: e.key.clone(),
138+
props: e.props.borrow(),
139+
helper: e.helper.copy(),
140+
}
141+
}
142+
}
143+
134144
mod private {
135145
use super::*;
136146

packages/iocraft/src/handler.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::ops::{Deref, DerefMut};
1+
use std::{
2+
mem,
3+
ops::{Deref, DerefMut},
4+
};
25

36
/// `Handler` is a type representing an optional event handler, commonly used for component properties.
47
///
@@ -11,6 +14,11 @@ impl<'a, T> Handler<'a, T> {
1114
pub fn is_default(&self) -> bool {
1215
!self.0
1316
}
17+
18+
/// Takes the handler, leaving a default-initialized handler in its place.
19+
pub fn take(&mut self) -> Self {
20+
mem::take(self)
21+
}
1422
}
1523

1624
impl<'a, T> Default for Handler<'a, T> {

0 commit comments

Comments
 (0)