diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9f03a5..a99f32d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ name: CI - on: pull_request: push: @@ -7,16 +6,13 @@ on: - main - master - develop - env: CARGO_TERM_COLOR: always - # ensure that the workflow is only triggered once per PR, subsequent pushes to the PR will cancel # and restart the workflow. See https://docs.github.com/en/actions/using-jobs/using-concurrency concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true - jobs: fmt: name: fmt @@ -85,6 +81,6 @@ jobs: if: hashFiles('Cargo.lock') == '' run: cargo generate-lockfile - name: cargo test --locked - run: cargo test --locked --all-features --all-targets + run: cargo test --locked --all-targets - name: Cache Cargo dependencies uses: Swatinem/rust-cache@v2 diff --git a/Cargo.lock b/Cargo.lock index 3ae14b1..defd5bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,12 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "castaway" version = "0.2.4" @@ -310,7 +316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -585,7 +591,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -779,6 +785,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + [[package]] name = "portable-atomic" version = "1.13.0" @@ -972,7 +984,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1083,6 +1095,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -1145,6 +1167,7 @@ dependencies = [ "crossterm", "ratatui", "thiserror 2.0.18", + "tokio", ] [[package]] @@ -1271,6 +1294,34 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1505,6 +1556,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1514,6 +1574,71 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/teatui/Cargo.toml b/teatui/Cargo.toml index 519adc2..d9192a5 100644 --- a/teatui/Cargo.toml +++ b/teatui/Cargo.toml @@ -1,12 +1,17 @@ [package] name = "teatui" -version = "0.2.1" +version = "0.3.0" description = "An elm-like abstraction over Ratatui" license.workspace = true authors.workspace = true edition.workspace = true +[features] +default = [] +tokio = ["dep:tokio"] + [dependencies] crossterm.workspace = true ratatui.workspace = true thiserror = "2" +tokio = { version = "1", features = ["full"], optional = true } diff --git a/teatui/src/effects.rs b/teatui/src/effects.rs index beb4d62..3edabf0 100644 --- a/teatui/src/effects.rs +++ b/teatui/src/effects.rs @@ -1,12 +1,16 @@ //! Actor responsible of processing side effects sent by the update actor. use std::sync::mpsc::{Receiver, SendError, Sender}; +#[cfg(feature = "tokio")] +use std::future::Future; + #[derive(thiserror::Error, Debug)] pub enum EffectsError { #[error("Failed to send message to update process")] MessageSend(#[from] SendError), } +#[cfg(not(feature = "tokio"))] pub(crate) fn run( effects_fn: F, rx: Receiver<(M, Eff)>, @@ -26,3 +30,39 @@ where } } } + +#[cfg(feature = "tokio")] +pub(crate) fn run_async( + effects_fn: F, + rx: Receiver<(M, Eff)>, + tx: Sender, +) -> Result<(), EffectsError> +where + M: Send + Sync + 'static, + Msg: Send + Sync + 'static, + Eff: Send + Sync + 'static, + Fut: Future> + Send, + F: Fn(&M, Eff) -> Fut + Send + Sync + 'static, +{ + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to build Tokio reactor for side-effects"); + + rt.block_on(async { + loop { + let Ok((model, effect)) = rx.recv() else { + break; + }; + + // We spawn the effect in the tokio reactor so they can run concurrently + let fut = effects_fn(&model, effect); + + if let Some(msg) = fut.await { + let _ = tx.send(msg); + } + } + }); + + Ok(()) +} diff --git a/teatui/src/lib.rs b/teatui/src/lib.rs index 1f94768..362489f 100644 --- a/teatui/src/lib.rs +++ b/teatui/src/lib.rs @@ -73,6 +73,7 @@ where /// - A `view` function, responsible for constructing the view from the model. /// /// - An `effects` function responsible for handling side effects. +#[cfg(not(feature = "tokio"))] pub fn start( init_fn: IF, update_fn: UF, @@ -88,67 +89,110 @@ where UF: Fn(M, Msg) -> Update + Send + Sync + 'static, VF: Fn(&M) -> W + Send + Sync + 'static, EF: Fn(&M, Eff) -> Option + Send + Sync + 'static, +{ + run_program(init_fn, update_fn, view_fn, move |effects_rx, update_tx| { + effects::run(effects_fn, effects_rx, update_tx) + }) +} + +/// Starts the runtime with asynchronous (Tokio) side effects. +#[cfg(feature = "tokio")] +pub fn start( + init_fn: IF, + update_fn: UF, + view_fn: VF, + effects_fn: EF, +) -> Result<(), ProgramError> +where + M: Clone + Send + Sync + 'static, + Eff: Debug + Send + Sync + 'static, + Msg: From + Sync + Send + 'static, + W: Widget, + IF: Fn() -> (M, Option) + Send + Sync + 'static, + UF: Fn(M, Msg) -> Update + Send + Sync + 'static, + VF: Fn(&M) -> W + Send + Sync + 'static, + EF: Fn(&M, Eff) -> Fut + Send + Sync + 'static, + Fut: std::future::Future> + Send, +{ + run_program(init_fn, update_fn, view_fn, move |effects_rx, update_tx| { + effects::run_async(effects_fn, effects_rx, update_tx) + }) +} + +/// Internal helper to abstract the common actor-spawning logic. +fn run_program( + init_fn: IF, + update_fn: UF, + view_fn: VF, + effects_fn: SF, +) -> Result<(), ProgramError> +where + M: Clone + Send + Sync + 'static, + Eff: Debug + Send + Sync + 'static, + Msg: From + Sync + Send + 'static, + W: Widget, + IF: Fn() -> (M, Option) + Send + Sync + 'static, + UF: Fn(M, Msg) -> Update + Send + Sync + 'static, + VF: Fn(&M) -> W + Send + Sync + 'static, + SF: FnOnce( + std::sync::mpsc::Receiver<(M, Eff)>, + std::sync::mpsc::Sender, + ) -> Result<(), EffectsError> + + Send + + Sync + + 'static, { let terminal = ratatui::init(); - // Channel for signaling when a task completes let (shutdown_tx, shutdown_rx) = channel::>>(); - - // Channels for inter-thread communication let (update_tx, update_rx) = channel::(); let (view_tx, view_rx) = channel::(); let (effects_tx, effects_rx) = channel::<(M, Eff)>(); - // Spawn order is important. - // If the view actor is started after the update actor, it could happen - // that both actors have an out of sync version of the model for a bit. + // Spawn View Actor thread::spawn({ let (model, _) = init_fn(); - let shutdown_tx = shutdown_tx.clone(); - move || { - let result = view::run(model, terminal, view_fn, view_rx) - .map_err(|err| ProgramError::ViewError(err)); - + let result = + view::run(model, terminal, view_fn, view_rx).map_err(ProgramError::ViewError); let _ = shutdown_tx.send(result); } }); + // Spawn Update Actor thread::spawn({ let shutdown_tx = shutdown_tx.clone(); let (model, effect) = init_fn(); - move || { let result = update::run(model, effect, update_fn, update_rx, view_tx, effects_tx) - .map_err(|err| ProgramError::UpdateError(err)); - + .map_err(ProgramError::UpdateError); let _ = shutdown_tx.send(result); } }); + // Spawn Effects Actor thread::spawn({ + let shutdown_tx = shutdown_tx.clone(); let update_tx = update_tx.clone(); - let shutdown_tx = shutdown_tx.clone(); + move || { - let result = effects::run(effects_fn, effects_rx, update_tx) - .map_err(|err| ProgramError::EffectsError(err)); + let result = effects_fn(effects_rx, update_tx).map_err(ProgramError::EffectsError); let _ = shutdown_tx.send(result); } }); + // Spawn Events Actor thread::spawn({ let shutdown_tx = shutdown_tx.clone(); - move || { - let result = events::run(update_tx).map_err(|err| ProgramError::EventLoopError(err)); + let result = events::run(update_tx).map_err(ProgramError::EventLoopError); let _ = shutdown_tx.send(result); } }); let result = shutdown_rx.recv().ok(); - ratatui::restore(); match result {