diff --git a/Cargo.toml b/Cargo.toml index e5ec20306..8291a290c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,3 +88,7 @@ harness = false [[bench]] name = "jsframework" harness = false + +[[bench]] +name = "tui_update" +harness = false \ No newline at end of file diff --git a/benches/tui_update.rs b/benches/tui_update.rs new file mode 100644 index 000000000..d46921a8d --- /dev/null +++ b/benches/tui_update.rs @@ -0,0 +1,220 @@ +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use dioxus::prelude::*; +use dioxus_tui::{Config, TuiContext}; + +criterion_group!(mbenches, tui_update); +criterion_main!(mbenches); + +/// This benchmarks the cache performance of the TUI for small edits by changing one box at a time. +fn tui_update(c: &mut Criterion) { + let mut group = c.benchmark_group("Update boxes"); + + // We can also use loops to define multiple benchmarks, even over multiple dimensions. + for size in 1..=8u32 { + let parameter_string = format!("{}", (3 * size).pow(2)); + group.bench_with_input( + BenchmarkId::new("size", parameter_string), + &size, + |b, size| { + b.iter(|| match size { + 1 => dioxus::tui::launch_cfg(app3, Config::default().with_headless()), + 2 => dioxus::tui::launch_cfg(app6, Config::default().with_headless()), + 3 => dioxus::tui::launch_cfg(app9, Config::default().with_headless()), + 4 => dioxus::tui::launch_cfg(app12, Config::default().with_headless()), + 5 => dioxus::tui::launch_cfg(app15, Config::default().with_headless()), + 6 => dioxus::tui::launch_cfg(app18, Config::default().with_headless()), + 7 => dioxus::tui::launch_cfg(app21, Config::default().with_headless()), + 8 => dioxus::tui::launch_cfg(app24, Config::default().with_headless()), + _ => (), + }) + }, + ); + } +} + +#[derive(Props, PartialEq)] +struct BoxProps { + x: usize, + y: usize, + hue: f32, + alpha: f32, +} +#[allow(non_snake_case)] +fn Box(cx: Scope) -> Element { + let count = use_state(&cx, || 0); + + let x = cx.props.x * 2; + let y = cx.props.y * 2; + let hue = cx.props.hue; + let display_hue = cx.props.hue as u32 / 10; + let count = count.get(); + let alpha = cx.props.alpha + (count % 100) as f32; + + cx.render(rsx! { + div { + left: "{x}%", + top: "{y}%", + width: "100%", + height: "100%", + background_color: "hsl({hue}, 100%, 50%, {alpha}%)", + align_items: "center", + p{"{display_hue:03}"} + } + }) +} + +#[derive(Props, PartialEq)] +struct GridProps { + size: usize, +} +#[allow(non_snake_case)] +fn Grid(cx: Scope) -> Element { + let size = cx.props.size; + let count = use_state(&cx, || 0); + let counts = use_ref(&cx, || vec![0; size * size]); + + let ctx: TuiContext = cx.consume_context().unwrap(); + if *count.get() + 1 >= (size * size) { + ctx.quit(); + } else { + counts.with_mut(|c| { + let i = *count.current(); + c[i] += 1; + c[i] = c[i] % 360; + }); + count.with_mut(|i| { + *i += 1; + *i = *i % (size * size); + }); + } + + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + flex_direction: "column", + (0..size).map(|x| + { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + flex_direction: "row", + (0..size).map(|y| + { + let alpha = y as f32*100.0/size as f32 + counts.read()[x*size + y] as f32; + let key = format!("{}-{}", x, y); + cx.render(rsx! { + Box{ + x: x, + y: y, + alpha: 100.0, + hue: alpha, + key: "{key}", + } + }) + } + ) + } + }) + } + ) + } + }) +} + +fn app3(cx: Scope) -> Element { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + Grid{ + size: 3, + } + } + }) +} + +fn app6(cx: Scope) -> Element { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + Grid{ + size: 6, + } + } + }) +} + +fn app9(cx: Scope) -> Element { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + Grid{ + size: 9, + } + } + }) +} + +fn app12(cx: Scope) -> Element { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + Grid{ + size: 12, + } + } + }) +} + +fn app15(cx: Scope) -> Element { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + Grid{ + size: 15, + } + } + }) +} + +fn app18(cx: Scope) -> Element { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + Grid{ + size: 18, + } + } + }) +} + +fn app21(cx: Scope) -> Element { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + Grid{ + size: 21, + } + } + }) +} + +fn app24(cx: Scope) -> Element { + cx.render(rsx! { + div{ + width: "100%", + height: "100%", + Grid{ + size: 24, + } + } + }) +} diff --git a/docs/reference/src/platforms/tui.md b/docs/reference/src/platforms/tui.md index e646bd4ba..2b5a3ef96 100644 --- a/docs/reference/src/platforms/tui.md +++ b/docs/reference/src/platforms/tui.md @@ -9,13 +9,12 @@ TUI support is currently quite experimental. Even the project name will change. ## Getting Set up -To tinker with TUI support, start by making a new package and adding our TUI package from git. +To tinker with TUI support, start by making a new package and adding our TUI feature. ```shell $ cargo new --bin demo $ cd demo -$ cargo add dioxus -$ cargo add rink --git https://github.com/DioxusLabs/rink.git +$ cargo add dioxus --features tui ``` @@ -27,10 +26,7 @@ Then, edit your `main.rs` with the basic template. use dioxus::prelude::*; fn main() { - let mut dom = VirtualDom::new(app); - dom.rebuild(); - - rink::render_vdom(&mut dom).unwrap(); + dioxus::tui::launch(app); } fn app(cx: Scope) -> Element { @@ -54,7 +50,44 @@ To run our app: $ cargo run ``` -Press "q" to close the app (yes, this is hardcoded, we are working on handlers to expose this in your code.) +Press "ctrl-c" to close the app. To switch from "ctrl-c" to just "q" to quit you can launch the app with a configuration to disable the default quit and use the root TuiContext to quit on your own. + +```rust +// main +use dioxus::events::{KeyCode, KeyboardEvent}; +use dioxus::prelude::*; +use dioxus::tui::TuiContext; + +fn main() { + dioxus::tui::launch_cfg( + app, + dioxus::tui::Config { + ctrl_c_quit: false, + // Some older terminals only support 16 colors or ANSI colors if your terminal is one of these change this to BaseColors or ANSI + rendering_mode: dioxus::tui::RenderingMode::Rgb, + }, + ); +} + +fn app(cx: Scope) -> Element { + let tui_ctx: TuiContext = cx.consume_context().unwrap(); + + cx.render(rsx! { + div { + width: "100%", + height: "10px", + background_color: "red", + justify_content: "center", + align_items: "center", + onkeydown: move |k: KeyboardEvent| if let KeyCode::Q = k.data.key_code { + tui_ctx.quit(); + }, + + "Hello world!" + } + }) +} +``` ## Notes diff --git a/examples/tui_color_test.rs b/examples/tui_color_test.rs index 358fae1f6..1a5a67457 100644 --- a/examples/tui_color_test.rs +++ b/examples/tui_color_test.rs @@ -3,9 +3,7 @@ use dioxus::prelude::*; fn main() { dioxus::tui::launch_cfg( app, - dioxus::tui::Config { - rendering_mode: dioxus::tui::RenderingMode::Ansi, - }, + dioxus::tui::Config::default().with_rendering_mode(dioxus::tui::RenderingMode::Ansi), ); } diff --git a/packages/tui/src/config.rs b/packages/tui/src/config.rs index 46a0926b8..fc2fb3da5 100644 --- a/packages/tui/src/config.rs +++ b/packages/tui/src/config.rs @@ -1,6 +1,49 @@ -#[derive(Default, Clone, Copy)] +#[derive(Clone, Copy)] +#[non_exhaustive] pub struct Config { - pub rendering_mode: RenderingMode, + pub(crate) rendering_mode: RenderingMode, + /// Controls if the terminal quit when the user presses `ctrl+c`? + /// To handle quiting on your own, use the [crate::TuiContext] root context. + pub(crate) ctrl_c_quit: bool, + /// Controls if the terminal should dislay anything, usefull for testing. + pub(crate) headless: bool, +} + +impl Config { + pub fn new() -> Self { + Self::default() + } + + pub fn with_rendering_mode(self, rendering_mode: RenderingMode) -> Self { + Self { + rendering_mode, + ..self + } + } + + pub fn with_ctrl_c_quit(self) -> Self { + Self { + ctrl_c_quit: true, + ..self + } + } + + pub fn with_headless(self) -> Self { + Self { + headless: true, + ..self + } + } +} + +impl Default for Config { + fn default() -> Self { + Self { + rendering_mode: Default::default(), + ctrl_c_quit: true, + headless: false, + } + } } #[derive(Clone, Copy)] diff --git a/packages/tui/src/lib.rs b/packages/tui/src/lib.rs index f90967802..1c4499e18 100644 --- a/packages/tui/src/lib.rs +++ b/packages/tui/src/lib.rs @@ -6,13 +6,19 @@ use crossterm::{ }; use dioxus_core::exports::futures_channel::mpsc::unbounded; use dioxus_core::*; -use futures::{channel::mpsc::UnboundedSender, pin_mut, StreamExt}; +use futures::{ + channel::mpsc::{UnboundedReceiver, UnboundedSender}, + pin_mut, StreamExt, +}; use std::{ collections::HashMap, io, time::{Duration, Instant}, }; -use stretch2::{prelude::Size, Stretch}; +use stretch2::{ + prelude::{Node, Size}, + Stretch, +}; use style::RinkStyle; use tui::{backend::CrosstermBackend, Terminal}; @@ -30,6 +36,16 @@ pub use hooks::*; pub use layout::*; pub use render::*; +#[derive(Clone)] +pub struct TuiContext { + tx: UnboundedSender, +} +impl TuiContext { + pub fn quit(&self) { + self.tx.unbounded_send(InputEvent::Close).unwrap(); + } +} + pub fn launch(app: Component<()>) { launch_cfg(app, Config::default()) } @@ -37,8 +53,34 @@ pub fn launch(app: Component<()>) { pub fn launch_cfg(app: Component<()>, cfg: Config) { let mut dom = VirtualDom::new(app); let (tx, rx) = unbounded(); + // Setup input handling + let (event_tx, event_rx) = unbounded(); + let event_tx_clone = event_tx.clone(); + if !cfg.headless { + std::thread::spawn(move || { + let tick_rate = Duration::from_millis(100); + let mut last_tick = Instant::now(); + loop { + // poll for tick rate duration, if no events, sent tick event. + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if crossterm::event::poll(timeout).unwrap() { + let evt = crossterm::event::read().unwrap(); + event_tx.unbounded_send(InputEvent::UserInput(evt)).unwrap(); + } + + if last_tick.elapsed() >= tick_rate { + event_tx.unbounded_send(InputEvent::Tick).unwrap(); + last_tick = Instant::now(); + } + } + }); + } let cx = dom.base_scope(); + cx.provide_root_context(TuiContext { tx: event_tx_clone }); let (handler, state) = RinkInputHandler::new(rx, cx); @@ -46,7 +88,7 @@ pub fn launch_cfg(app: Component<()>, cfg: Config) { dom.rebuild(); - render_vdom(&mut dom, tx, handler, cfg).unwrap(); + render_vdom(&mut dom, event_rx, tx, handler, cfg).unwrap(); } pub struct TuiNode<'a> { @@ -56,35 +98,13 @@ pub struct TuiNode<'a> { pub node: &'a VNode<'a>, } -pub fn render_vdom( +fn render_vdom( vdom: &mut VirtualDom, + mut event_reciever: UnboundedReceiver, ctx: UnboundedSender, handler: RinkInputHandler, cfg: Config, ) -> Result<()> { - // Setup input handling - let (tx, mut rx) = unbounded(); - std::thread::spawn(move || { - let tick_rate = Duration::from_millis(100); - let mut last_tick = Instant::now(); - loop { - // poll for tick rate duration, if no events, sent tick event. - let timeout = tick_rate - .checked_sub(last_tick.elapsed()) - .unwrap_or_else(|| Duration::from_secs(0)); - - if crossterm::event::poll(timeout).unwrap() { - let evt = crossterm::event::read().unwrap(); - tx.unbounded_send(InputEvent::UserInput(evt)).unwrap(); - } - - if last_tick.elapsed() >= tick_rate { - tx.unbounded_send(InputEvent::Tick).unwrap(); - last_tick = Instant::now(); - } - } - }); - tokio::runtime::Builder::new_current_thread() .enable_all() .build()? @@ -92,13 +112,17 @@ pub fn render_vdom( /* Get the terminal to calcualte the layout from */ - enable_raw_mode().unwrap(); - let mut stdout = std::io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap(); - let backend = CrosstermBackend::new(io::stdout()); - let mut terminal = Terminal::new(backend).unwrap(); + let mut terminal = (!cfg.headless).then(|| { + enable_raw_mode().unwrap(); + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap(); + let backend = CrosstermBackend::new(io::stdout()); + Terminal::new(backend).unwrap() + }); - terminal.clear().unwrap(); + if let Some(terminal) = &mut terminal { + terminal.clear().unwrap(); + } loop { /* @@ -126,34 +150,51 @@ pub fn render_vdom( let root_layout = nodes[&node_id].layout; let mut events = Vec::new(); - terminal.draw(|frame| { - // size is guaranteed to not change when rendering - let dims = frame.size(); + fn resize(dims: tui::layout::Rect, stretch: &mut Stretch, root_layout: Node) { let width = dims.width; let height = dims.height; - layout + + stretch .compute_layout( root_layout, Size { - width: stretch2::prelude::Number::Defined(width as f32), - height: stretch2::prelude::Number::Defined(height as f32), + width: stretch2::prelude::Number::Defined((width - 1) as f32), + height: stretch2::prelude::Number::Defined((height - 1) as f32), }, ) .unwrap(); + } - // resolve events before rendering - events = handler.get_events(vdom, &layout, &mut nodes, root_node); - render::render_vnode( - frame, - &layout, - &mut nodes, - vdom, - root_node, - &RinkStyle::default(), - cfg, + if let Some(terminal) = &mut terminal { + terminal.draw(|frame| { + // size is guaranteed to not change when rendering + resize(frame.size(), &mut layout, root_layout); + + // resolve events before rendering + events = handler.get_events(vdom, &layout, &mut nodes, root_node); + render::render_vnode( + frame, + &layout, + &mut nodes, + vdom, + root_node, + &RinkStyle::default(), + cfg, + ); + assert!(nodes.is_empty()); + })?; + } else { + resize( + tui::layout::Rect { + x: 0, + y: 0, + width: 100, + height: 100, + }, + &mut layout, + root_layout, ); - assert!(nodes.is_empty()); - })?; + } for e in events { vdom.handle_message(SchedulerMsg::Event(e)); @@ -164,7 +205,7 @@ pub fn render_vdom( let wait = vdom.wait_for_work(); pin_mut!(wait); - match select(wait, rx.next()).await { + match select(wait, event_reciever.next()).await { Either::Left((_a, _b)) => { // } @@ -174,6 +215,7 @@ pub fn render_vdom( TermEvent::Key(key) => { if matches!(key.code, KeyCode::Char('C' | 'c')) && key.modifiers.contains(KeyModifiers::CONTROL) + && cfg.ctrl_c_quit { break; } @@ -194,13 +236,15 @@ pub fn render_vdom( vdom.work_with_deadline(|| false); } - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; + if let Some(terminal) = &mut terminal { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + } Ok(()) })