diff --git a/Cargo.toml b/Cargo.toml index bbcd34f..b9d11fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ anyhow = "1.0.89" surf = { version = "2.3.2", default-features = false, features = ["h1-client"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" +mexprp = { version = "0.3.1", default-features = false } diff --git a/README.md b/README.md index fa265a0..1e558c4 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ forms, and more! + ## Shoutouts diff --git a/examples/README.md b/examples/README.md index 1118d52..55dec85 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,6 +9,7 @@ To run any of the examples, use `cargo run --example NAME`. For example, to run |Example|Preview| |---|:---:| |[borders.rs](./borders.rs)
Showcases various border styles.|![preview](./images/borders.png)| +|[calculator.rs](./calculator.rs)
Uses clickable buttons to provide a calculator app with light/dark mode themes.|![preview](./images/calculator.png)| |[context.rs](./context.rs)
Demonstrates using a custom context via `ContextProvider` and `use_context`.|![preview](./images/context.png)| |[counter.rs](./counter.rs)
Renders a dynamic component which spawns a future to increment a counter every 100ms.|![preview](./images/counter.png)| |[form.rs](./form.rs)
Displays a form prompting the user for input into multiple text fields. Uses mutable reference props to surface the user's input to the caller once the form is submitted.|![preview](./images/form.png)| diff --git a/examples/calculator.rs b/examples/calculator.rs new file mode 100644 index 0000000..6dca576 --- /dev/null +++ b/examples/calculator.rs @@ -0,0 +1,408 @@ +use iocraft::prelude::*; + +#[derive(Clone, Copy, Default)] +enum Theme { + #[default] + Dark, + Light, +} + +#[derive(Clone, Copy)] +struct ButtonStyle { + color: Color, + text_color: Color, + trim_color: Color, +} + +// https://www.ditig.com/publications/256-colors-cheat-sheet +impl Theme { + fn toggled(&self) -> Self { + match self { + Self::Dark => Self::Light, + Self::Light => Self::Dark, + } + } + + fn background_color(&self) -> Color { + match self { + Self::Dark => Color::AnsiValue(237), + Self::Light => Color::AnsiValue(253), + } + } + + fn footer_background_color(&self) -> Color { + match self { + Self::Dark => Color::AnsiValue(253), + Self::Light => Color::AnsiValue(237), + } + } + + fn footer_text_color(&self) -> Color { + match self { + Self::Dark => Color::AnsiValue(237), + Self::Light => Color::AnsiValue(253), + } + } + + fn screen_color(&self) -> Color { + Color::AnsiValue(68) + } + + fn screen_text_color(&self) -> Color { + Color::AnsiValue(231) + } + + fn screen_trim_color(&self) -> Color { + Color::AnsiValue(75) + } + + fn numpad_button_style(&self) -> ButtonStyle { + match self { + Self::Dark => ButtonStyle { + color: Color::AnsiValue(239), + text_color: Color::AnsiValue(231), + trim_color: Color::AnsiValue(243), + }, + Self::Light => ButtonStyle { + color: Color::AnsiValue(251), + text_color: Color::AnsiValue(16), + trim_color: Color::AnsiValue(255), + }, + } + } + + fn operator_button_style(&self) -> ButtonStyle { + ButtonStyle { + color: Color::AnsiValue(172), + text_color: Color::AnsiValue(231), + trim_color: Color::AnsiValue(215), + } + } + + fn clear_button_style(&self) -> ButtonStyle { + ButtonStyle { + color: Color::AnsiValue(161), + text_color: Color::AnsiValue(231), + trim_color: Color::AnsiValue(205), + } + } + + fn fn_button_style(&self) -> ButtonStyle { + ButtonStyle { + color: Color::AnsiValue(66), + text_color: Color::AnsiValue(231), + trim_color: Color::AnsiValue(115), + } + } +} + +#[derive(Default, Props)] +struct ScreenProps { + content: String, +} + +#[component] +fn Screen(hooks: Hooks, props: &ScreenProps) -> impl Into> { + let theme = hooks.use_context::(); + element! { + Box( + width: 100pct, + border_style: BorderStyle::Custom(BorderCharacters { + top: '▁', + ..Default::default() + }), + border_edges: Edges::Top, + border_color: theme.screen_trim_color(), + ) { + Box( + width: 100pct, + background_color: theme.screen_color(), + padding: 1, + justify_content: JustifyContent::End, + ) { + Text( + content: &props.content, + align: TextAlign::Right, + color: theme.screen_text_color(), + ) + } + } + } +} + +#[derive(Default, Props)] +struct ButtonProps { + label: String, + style: Option, + on_click: Handler<'static, ()>, +} + +#[component] +fn Button(props: &mut ButtonProps, mut hooks: Hooks) -> 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, + ) { + 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, + ) + } + } + } +} + +#[component] +fn Calculator(mut hooks: Hooks) -> impl Into> { + let theme = hooks.use_context::(); + let numpad_button_style = theme.numpad_button_style(); + let operator_button_style = theme.operator_button_style(); + let fn_button_style = theme.fn_button_style(); + let expr = hooks.use_state(|| "0".to_string()); + let clear_on_number = hooks.use_state(|| true); + + let handle_backspace = move || { + let new_expr = expr + .read() + .chars() + .take(expr.read().len() - 1) + .collect::(); + if new_expr.is_empty() { + expr.set("0".to_string()); + clear_on_number.set(true); + } else { + expr.set(new_expr); + clear_on_number.set(false); + } + }; + + let handle_number = move |n: u8| { + if clear_on_number.get() { + expr.set(n.to_string()); + clear_on_number.set(false); + } else { + expr.set(expr.to_string() + &n.to_string()); + } + }; + + let handle_decimal = move || { + if clear_on_number.get() { + expr.set("0.".to_string()); + clear_on_number.set(false); + } else if expr.read().chars().last() != Some('.') { + expr.set(expr.to_string() + "."); + } + }; + + let handle_clear = move || { + expr.set("0".to_string()); + clear_on_number.set(true); + }; + + let has_trailing_operator = matches!( + expr.read().chars().last(), + Some('+') | Some('-') | Some('×') | Some('÷') + ); + + let handle_operator = move |op: char| { + if clear_on_number.get() { + clear_on_number.set(false); + } + if !has_trailing_operator { + expr.set(expr.to_string() + &op.to_string()); + } + }; + + let handle_percent = move || { + if clear_on_number.get() { + clear_on_number.set(false); + } + if !has_trailing_operator { + expr.set(expr.to_string() + "%"); + } + }; + + let handle_plus_minus = move || { + if clear_on_number.get() { + clear_on_number.set(false); + } + if !has_trailing_operator { + expr.set(format!("-({})", expr)); + } + }; + + let handle_equals = move || { + if let Ok(f) = mexprp::eval::(&expr.to_string()) { + expr.set(f.to_string()); + clear_on_number.set(true); + } + }; + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => { + match code { + KeyCode::Char('/') => handle_operator('÷'), + KeyCode::Char('*') => handle_operator('×'), + KeyCode::Char('+') => handle_operator('+'), + KeyCode::Char('-') => handle_operator('-'), + KeyCode::Char('0') => handle_number(0), + KeyCode::Char('1') => handle_number(1), + KeyCode::Char('2') => handle_number(2), + KeyCode::Char('3') => handle_number(3), + KeyCode::Char('4') => handle_number(4), + KeyCode::Char('5') => handle_number(5), + KeyCode::Char('6') => handle_number(6), + KeyCode::Char('7') => handle_number(7), + KeyCode::Char('8') => handle_number(8), + KeyCode::Char('9') => handle_number(9), + KeyCode::Char('.') => handle_decimal(), + KeyCode::Char('%') => handle_percent(), + KeyCode::Char('=') | KeyCode::Enter => handle_equals(), + KeyCode::Backspace => handle_backspace(), + KeyCode::Char('c') => handle_clear(), + _ => {} + } + } + _ => {} + } + }); + + element! { + Box( + width: 100pct, + height: 100pct, + flex_direction: FlexDirection::Column, + padding_left: 1, + padding_right: 1, + ) { + Box( + padding_left: 1, + padding_right: 1, + ) { + 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('÷')) + } + 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('×')) + } + 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('-')) + } + 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('+')) + } + 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()) + } + } + } +} + +#[component] +fn App(mut hooks: Hooks) -> impl Into> { + let (width, height) = hooks.use_terminal_size(); + let mut system = hooks.use_context_mut::(); + let should_exit = hooks.use_state(|| false); + let theme = hooks.use_state(|| Theme::default()); + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => { + match code { + KeyCode::Char('q') => should_exit.set(true), + KeyCode::Char('t') => theme.set(theme.get().toggled()), + _ => {} + } + } + _ => {} + } + }); + + if should_exit.get() { + system.exit(); + } + + element! { + Box( + // subtract one in case there's a scrollbar + width: width - 1, + height: height, + background_color: theme.get().background_color(), + flex_direction: FlexDirection::Column, + gap: 1, + ) { + Box( + flex_grow: 1.0, + ) { + Box( + max_width: 120, + max_height: 40, + flex_grow: 1.0, + ) { + ContextProvider(value: Context::owned(theme.get())) { + Calculator + } + } + } + Box( + height: 1, + background_color: theme.get().footer_background_color(), + padding_left: 1, + ) { + Text(content: "[T] Toggle Theme [Q] Quit", color: theme.get().footer_text_color()) + } + } + } +} + +fn main() { + smol::block_on(element!(App).fullscreen()).unwrap(); +} diff --git a/examples/images/calculator.png b/examples/images/calculator.png new file mode 100644 index 0000000..2b809f0 Binary files /dev/null and b/examples/images/calculator.png differ diff --git a/packages/iocraft/src/component.rs b/packages/iocraft/src/component.rs index b085a84..08a3934 100644 --- a/packages/iocraft/src/component.rs +++ b/packages/iocraft/src/component.rs @@ -186,10 +186,36 @@ impl InstantiatedComponent { } pub fn draw(&mut self, drawer: &mut ComponentDrawer<'_>) { - self.hooks.pre_component_draw(drawer); - self.component.draw(drawer); + if self.has_transparent_layout { + // If the component has a transparent layout, provide the first child's layout to the + // hooks and component. + if let Some((_, child)) = self.children.components.iter().next().as_ref() { + drawer.for_child_node_layout(child.node_id, |drawer| { + self.hooks.pre_component_draw(drawer); + self.component.draw(drawer); + }); + } else { + self.hooks.pre_component_draw(drawer); + self.component.draw(drawer); + } + } else { + self.hooks.pre_component_draw(drawer); + self.component.draw(drawer); + } + self.children.draw(drawer); - self.hooks.post_component_draw(drawer); + + if self.has_transparent_layout { + if let Some((_, child)) = self.children.components.iter().next().as_ref() { + drawer.for_child_node_layout(child.node_id, |drawer| { + self.hooks.post_component_draw(drawer); + }); + } else { + self.hooks.post_component_draw(drawer); + } + } else { + self.hooks.post_component_draw(drawer); + } } pub async fn wait(&mut self) { diff --git a/packages/iocraft/src/handler.rs b/packages/iocraft/src/handler.rs index ab6aac3..86bc895 100644 --- a/packages/iocraft/src/handler.rs +++ b/packages/iocraft/src/handler.rs @@ -4,11 +4,18 @@ use std::ops::{Deref, DerefMut}; /// /// Any function that takes a single argument and returns `()` can be converted into a `Handler`, /// and it can be invoked using function call syntax. -pub struct Handler<'a, T>(Box); +pub struct Handler<'a, T>(bool, Box); + +impl<'a, T> Handler<'a, T> { + /// Returns `true` if the handler was default-initialized. + pub fn is_default(&self) -> bool { + !self.0 + } +} impl<'a, T> Default for Handler<'a, T> { fn default() -> Self { - Self::from(|_| {}) + Self(false, Box::new(|_| {})) } } @@ -17,7 +24,7 @@ where F: FnMut(T) + Send + 'a, { fn from(f: F) -> Self { - Self(Box::new(f)) + Self(true, Box::new(f)) } } @@ -25,13 +32,13 @@ impl<'a, T: 'a> Deref for Handler<'a, T> { type Target = dyn FnMut(T) + Send + 'a; fn deref(&self) -> &Self::Target { - &self.0 + &self.1 } } impl<'a, T: 'a> DerefMut for Handler<'a, T> { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + &mut self.1 } } @@ -44,11 +51,13 @@ mod tests { let mut handler = Handler::::default(); handler(0); handler(0); + assert!(handler.is_default()); let mut handler = Handler::from(|value| { assert_eq!(value, 42); }); handler(42); handler(42); + assert!(!handler.is_default()); } } diff --git a/packages/iocraft/src/hooks/use_terminal_events.rs b/packages/iocraft/src/hooks/use_terminal_events.rs index 8ef1305..81506a1 100644 --- a/packages/iocraft/src/hooks/use_terminal_events.rs +++ b/packages/iocraft/src/hooks/use_terminal_events.rs @@ -1,9 +1,10 @@ -use crate::{ComponentUpdater, Hook, Hooks, TerminalEvent, TerminalEvents}; +use crate::{ComponentUpdater, FullscreenMouseEvent, Hook, Hooks, TerminalEvent, TerminalEvents}; use futures::stream::Stream; use std::{ pin::{pin, Pin}, task::{Context, Poll}, }; +use taffy::{Point, Size}; /// `UseTerminalEvents` is a hook that allows you to listen for user input such as key strokes. /// @@ -84,9 +85,22 @@ use std::{ /// ``` pub trait UseTerminalEvents { /// Defines a callback to be invoked whenever a terminal event occurs. + /// + /// This hook will be called for all terminal events, including those that occur outside of the + /// component. If you only want to listen for events within the component, use + /// [`Self::use_local_terminal_events`] instead. fn use_terminal_events(&mut self, f: F) where F: FnMut(TerminalEvent) + Send + 'static; + + /// Defines a callback to be invoked whenever a terminal event occurs within a component. + /// + /// Unlike [`Self::use_terminal_events`], this hook will not be called for events such as mouse + /// events that occur outside of the component. Furthermore, coordinates will be translated to + /// component-local coordinates. + fn use_local_terminal_events(&mut self, f: F) + where + F: FnMut(TerminalEvent) + Send + 'static; } impl UseTerminalEvents for Hooks<'_, '_> { @@ -96,6 +110,20 @@ impl UseTerminalEvents for Hooks<'_, '_> { { self.use_hook(move || UseTerminalEventsImpl { events: None, + component_location: Default::default(), + in_component: false, + f: Box::new(f), + }); + } + + fn use_local_terminal_events(&mut self, f: F) + where + F: FnMut(TerminalEvent) + Send + 'static, + { + self.use_hook(move || UseTerminalEventsImpl { + events: None, + component_location: Default::default(), + in_component: true, f: Box::new(f), }); } @@ -103,6 +131,8 @@ impl UseTerminalEvents for Hooks<'_, '_> { struct UseTerminalEventsImpl { events: Option, + component_location: (Point, Size), + in_component: bool, f: Box, } @@ -113,7 +143,29 @@ impl Hook for UseTerminalEventsImpl { .as_mut() .map(|events| pin!(events).poll_next(cx)) { - (self.f)(event); + if self.in_component { + let (location, size) = self.component_location; + match event { + TerminalEvent::FullscreenMouse(event) => { + if event.row >= location.y && event.column >= location.x { + let row = event.row - location.y; + let column = event.column - location.x; + if row < size.height && column < size.width { + (self.f)(TerminalEvent::FullscreenMouse(FullscreenMouseEvent { + row, + column, + ..event + })); + } + } + } + TerminalEvent::Key(_) | TerminalEvent::Resize(..) => { + (self.f)(event); + } + } + } else { + (self.f)(event); + } } Poll::Pending } @@ -123,11 +175,16 @@ impl Hook for UseTerminalEventsImpl { self.events = updater.terminal_events(); } } + + fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) { + self.component_location = (drawer.canvas_position(), drawer.size()); + } } #[cfg(test)] mod tests { use crate::prelude::*; + use crossterm::event::MouseButton; use futures::stream::{self, StreamExt}; use macro_rules_attribute::apply; use smol_macros::test; @@ -164,4 +221,54 @@ mod tests { let expected = vec!["", "received event\n"]; assert_eq!(actual, expected); } + + #[component] + fn MyClickableComponent(mut hooks: Hooks) -> impl Into> { + let mut system = hooks.use_context_mut::(); + let should_exit = hooks.use_state(|| false); + hooks.use_local_terminal_events(move |event| { + if let TerminalEvent::FullscreenMouse(FullscreenMouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + row, + column, + .. + }) = event + { + assert_eq!(row, 8); + assert_eq!(column, 8); + should_exit.set(true); + } + }); + + if should_exit.get() { + system.exit(); + element!(Text(content:"received click")).into_any() + } else { + element!(Box(width: 10, height: 10)).into_any() + } + } + + #[apply(test!)] + async fn test_use_local_terminal_events() { + let canvases: Vec<_> = element! { + Box(padding: 2) { + MyClickableComponent + } + } + .mock_terminal_render_loop(MockTerminalConfig::with_events(stream::iter(vec![ + TerminalEvent::FullscreenMouse(FullscreenMouseEvent { + column: 10, + row: 10, + modifiers: KeyModifiers::empty(), + kind: MouseEventKind::Down(MouseButton::Left), + }), + ]))) + .collect() + .await; + let actual = canvases + .iter() + .map(|c| c.to_string().trim().to_string()) + .collect::>(); + assert_eq!(actual, vec!["", "received click"]); + } } diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index 0ce3da5..b02bee5 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -19,7 +19,7 @@ use std::{ }; // Re-exports for basic types. -pub use crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers}; +pub use crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers, MouseEventKind}; /// An event fired when a key is pressed. #[derive(Clone, Debug)] @@ -34,12 +34,31 @@ pub struct KeyEvent { pub kind: KeyEventKind, } +/// An event fired when the mouse is moved, clicked, scrolled, etc. in fullscreen mode. +#[non_exhaustive] +#[derive(Clone, Debug)] +pub struct FullscreenMouseEvent { + /// The modifiers that were active when the event occurred. + pub modifiers: KeyModifiers, + + /// The column that the event occurred on. + pub column: u16, + + /// The row that the event occurred on. + pub row: u16, + + /// The kind of mouse event. + pub kind: MouseEventKind, +} + /// An event fired by the terminal. #[non_exhaustive] #[derive(Clone, Debug)] pub enum TerminalEvent { /// A key event, fired when a key is pressed. Key(KeyEvent), + /// A mouse event, fired when the mouse is moved, clicked, scrolled, etc. in fullscreen mode. + FullscreenMouse(FullscreenMouseEvent), /// A resize event, fired when the terminal is resized. Resize(u16, u16), } @@ -80,6 +99,7 @@ struct StdTerminal { dest: io::Stdout, fullscreen: bool, raw_mode_enabled: bool, + enabled_keyboard_enhancement: bool, prev_canvas_height: u16, } @@ -135,6 +155,14 @@ impl TerminalImpl for StdTerminal { modifiers: event.modifiers, kind: event.kind, })), + Ok(Event::Mouse(event)) => { + Some(TerminalEvent::FullscreenMouse(FullscreenMouseEvent { + modifiers: event.modifiers, + column: event.column, + row: event.row, + kind: event.kind, + })) + } Ok(Event::Resize(width, height)) => Some(TerminalEvent::Resize(width, height)), _ => None, } @@ -157,6 +185,7 @@ impl StdTerminal { dest, fullscreen, raw_mode_enabled: false, + enabled_keyboard_enhancement: false, prev_canvas_height: 0, }) } @@ -171,6 +200,7 @@ impl StdTerminal { event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES ) )?; + self.enabled_keyboard_enhancement = true; } if self.fullscreen { execute!(self.dest, event::EnableMouseCapture)?; @@ -181,7 +211,7 @@ impl StdTerminal { if self.fullscreen { execute!(self.dest, event::DisableMouseCapture)?; } - if terminal::supports_keyboard_enhancement()? { + if self.enabled_keyboard_enhancement { execute!(self.dest, event::PopKeyboardEnhancementFlags)?; } }