feat: provide a new tokio feature for async effects support via tokio

This commit is contained in:
JasterV 2026-01-29 15:00:02 +01:00
parent 2825688c30
commit f32c6e9dd9
5 changed files with 239 additions and 29 deletions

View file

@ -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

131
Cargo.lock generated
View file

@ -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"

View file

@ -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 }

View file

@ -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<M> {
#[error("Failed to send message to update process")]
MessageSend(#[from] SendError<M>),
}
#[cfg(not(feature = "tokio"))]
pub(crate) fn run<M, Msg, Eff, F>(
effects_fn: F,
rx: Receiver<(M, Eff)>,
@ -26,3 +30,39 @@ where
}
}
}
#[cfg(feature = "tokio")]
pub(crate) fn run_async<M, Msg, Eff, F, Fut>(
effects_fn: F,
rx: Receiver<(M, Eff)>,
tx: Sender<Msg>,
) -> Result<(), EffectsError<Msg>>
where
M: Send + Sync + 'static,
Msg: Send + Sync + 'static,
Eff: Send + Sync + 'static,
Fut: Future<Output = Option<Msg>> + 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(())
}

View file

@ -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<M, Msg, Eff, W, IF, UF, VF, EF>(
init_fn: IF,
update_fn: UF,
@ -88,67 +89,110 @@ where
UF: Fn(M, Msg) -> Update<M, Eff> + Send + Sync + 'static,
VF: Fn(&M) -> W + Send + Sync + 'static,
EF: Fn(&M, Eff) -> Option<Msg> + 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<M, Msg, Eff, W, IF, UF, VF, EF, Fut>(
init_fn: IF,
update_fn: UF,
view_fn: VF,
effects_fn: EF,
) -> Result<(), ProgramError<M, Msg, Eff>>
where
M: Clone + Send + Sync + 'static,
Eff: Debug + Send + Sync + 'static,
Msg: From<crossterm::event::Event> + Sync + Send + 'static,
W: Widget,
IF: Fn() -> (M, Option<Eff>) + Send + Sync + 'static,
UF: Fn(M, Msg) -> Update<M, Eff> + Send + Sync + 'static,
VF: Fn(&M) -> W + Send + Sync + 'static,
EF: Fn(&M, Eff) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Option<Msg>> + 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<M, Msg, Eff, W, IF, UF, VF, SF>(
init_fn: IF,
update_fn: UF,
view_fn: VF,
effects_fn: SF,
) -> Result<(), ProgramError<M, Msg, Eff>>
where
M: Clone + Send + Sync + 'static,
Eff: Debug + Send + Sync + 'static,
Msg: From<crossterm::event::Event> + Sync + Send + 'static,
W: Widget,
IF: Fn() -> (M, Option<Eff>) + Send + Sync + 'static,
UF: Fn(M, Msg) -> Update<M, Eff> + Send + Sync + 'static,
VF: Fn(&M) -> W + Send + Sync + 'static,
SF: FnOnce(
std::sync::mpsc::Receiver<(M, Eff)>,
std::sync::mpsc::Sender<Msg>,
) -> Result<(), EffectsError<Msg>>
+ Send
+ Sync
+ 'static,
{
let terminal = ratatui::init();
// Channel for signaling when a task completes
let (shutdown_tx, shutdown_rx) = channel::<Result<(), ProgramError<M, Msg, Eff>>>();
// Channels for inter-thread communication
let (update_tx, update_rx) = channel::<Msg>();
let (view_tx, view_rx) = channel::<M>();
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 {