From 1b792e6ad0eab6dd8a2ffbe391a8f1896d8ff8b7 Mon Sep 17 00:00:00 2001 From: JasterV <49537445+JasterV@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:14:51 +0200 Subject: [PATCH] refactor: abstracted the actors framework into a separate library --- Cargo.lock | 28 ++-- Cargo.toml | 8 +- src/counter/Cargo.toml | 12 ++ src/counter/src/main.rs | 133 ++++++++++++++++++ src/{ => counter/src}/model.rs | 5 +- src/events.rs | 1 - src/framework/Cargo.toml | 11 ++ src/framework/src/effects.rs | 20 +++ .../actor.rs => framework/src/events.rs} | 8 +- src/framework/src/lib.rs | 82 +++++++++++ src/framework/src/update.rs | 46 ++++++ src/framework/src/view.rs | 42 ++++++ src/main.rs | 55 -------- src/update.rs | 2 - src/update/actor.rs | 27 ---- src/update/core.rs | 15 -- src/view.rs | 2 - src/view/actor.rs | 21 --- src/view/core.rs | 33 ----- 19 files changed, 377 insertions(+), 174 deletions(-) create mode 100644 src/counter/Cargo.toml create mode 100644 src/counter/src/main.rs rename src/{ => counter/src}/model.rs (97%) delete mode 100644 src/events.rs create mode 100644 src/framework/Cargo.toml create mode 100644 src/framework/src/effects.rs rename src/{events/actor.rs => framework/src/events.rs} (52%) create mode 100644 src/framework/src/lib.rs create mode 100644 src/framework/src/update.rs create mode 100644 src/framework/src/view.rs delete mode 100644 src/main.rs delete mode 100644 src/update.rs delete mode 100644 src/update/actor.rs delete mode 100644 src/update/core.rs delete mode 100644 src/view.rs delete mode 100644 src/view/actor.rs delete mode 100644 src/view/core.rs diff --git a/Cargo.lock b/Cargo.lock index 7846c69..7e2b2b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "counter" +version = "0.1.0" +dependencies = [ + "color-eyre", + "crossterm 0.29.0", + "framework", + "ratatui", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -273,6 +283,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "framework" +version = "0.1.0" +dependencies = [ + "color-eyre", + "crossterm 0.29.0", + "ratatui", +] + [[package]] name = "gimli" version = "0.31.1" @@ -519,15 +538,6 @@ dependencies = [ "unicode-width 0.2.0", ] -[[package]] -name = "ratatui-playground" -version = "0.1.0" -dependencies = [ - "color-eyre", - "crossterm 0.29.0", - "ratatui", -] - [[package]] name = "redox_syscall" version = "0.5.17" diff --git a/Cargo.toml b/Cargo.toml index 7b4bf54..a4aa805 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,8 @@ -[package] +[workspace] +resolver = "3" +members = ["src/*"] + +[workspace.package] name = "ratatui-playground" version = "0.1.0" description = "A playground workspace for ratatui" @@ -6,7 +10,7 @@ authors = ["JasterV <49537445+JasterV@users.noreply.github.com>"] license = "MIT" edition = "2024" -[dependencies] +[workspace.dependencies] color-eyre = "0.6.3" crossterm = "0.29.0" ratatui = "0.29.0" diff --git a/src/counter/Cargo.toml b/src/counter/Cargo.toml new file mode 100644 index 0000000..4a0b058 --- /dev/null +++ b/src/counter/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "counter" +publish = false +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +color-eyre.workspace = true +crossterm.workspace = true +ratatui.workspace = true +framework = { path = "../framework" } diff --git a/src/counter/src/main.rs b/src/counter/src/main.rs new file mode 100644 index 0000000..c579dc7 --- /dev/null +++ b/src/counter/src/main.rs @@ -0,0 +1,133 @@ +use color_eyre::Result; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use framework::{Update, View}; +use ratatui::{ + style::Stylize, + text::Line, + widgets::{Block, Paragraph}, +}; + +fn main() -> Result<()> { + color_eyre::install()?; + let result = framework::start(Model::default(), update, view, run_effects); + result +} + +/// Defines the state of the application +#[derive(Debug, Clone, Default)] +pub struct Model { + pub counter: u64, +} + +impl Model { + pub fn increment_counter(model: Model) -> Model { + Model { + counter: model.counter + 1, + } + } + + pub fn decrement_counter(model: Model) -> Model { + let counter = if model.counter == 0 { + 0 + } else { + model.counter - 1 + }; + + Model { counter } + } +} + +/// Possible side effects to execute +pub enum Effect {} + +/// Messages that represent a change of state in the application +pub enum Message { + IncCounter, + DecCounter, + Exit, + NoOp, +} + +impl From for Message { + fn from(value: Event) -> Self { + match value { + Event::Key(KeyEvent { + code: KeyCode::Esc | KeyCode::Char('q'), + kind: KeyEventKind::Press, + state: _, + modifiers: _, + }) => Self::Exit, + + Event::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press, + state: _, + modifiers: _, + }) => Self::IncCounter, + + Event::Key(KeyEvent { + code: KeyCode::Left, + kind: KeyEventKind::Press, + state: _, + modifiers: _, + }) => Self::DecCounter, + + Event::Key(KeyEvent { + code: KeyCode::Char('c') | KeyCode::Char('C'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: _, + }) => Self::Exit, + + Event::FocusGained + | Event::FocusLost + | Event::Key(_) + | Event::Mouse(_) + | Event::Paste(_) + | Event::Resize(_, _) => Self::NoOp, + } + } +} + +/// Elm-like update function. +/// +/// Given the current state (model) and an incoming message from the outside world, +/// return a new state and a side effect. +pub fn update(model: Model, msg: Message) -> Result> { + match msg { + Message::Exit => Ok(Update::Exit), + Message::NoOp => Ok(Update::Next(model)), + Message::IncCounter => Ok(Update::Next(Model::increment_counter(model))), + Message::DecCounter => Ok(Update::Next(Model::decrement_counter(model))), + } +} + +pub fn run_effects(_model: &Model, _effect: Effect) -> Result> { + Ok(None) +} + +/// Elm-like View function. +/// +/// Given the current state (read-only), return a drawable widget. +pub fn view(model: &Model) -> Result { + let counter = model.counter; + + let title = Line::from("Ratatui Actor-based Counter") + .bold() + .blue() + .centered(); + + let text = format!( + r#"Counter TUI! + +Counter: {counter} + +Press `Esc`, `Ctrl-C` or `q` to stop running."# + ); + + let widget = Paragraph::new(text) + .block(Block::bordered().title(title)) + .centered(); + + Ok(View::new(widget)) +} diff --git a/src/model.rs b/src/counter/src/model.rs similarity index 97% rename from src/model.rs rename to src/counter/src/model.rs index e2de85a..3c43a66 100644 --- a/src/model.rs +++ b/src/counter/src/model.rs @@ -27,10 +27,7 @@ impl Model { } } -pub enum Effect { - Stop, - Continue, -} +pub enum Effect {} pub enum Message { IncCounter, diff --git a/src/events.rs b/src/events.rs deleted file mode 100644 index 5dd69d1..0000000 --- a/src/events.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod actor; diff --git a/src/framework/Cargo.toml b/src/framework/Cargo.toml new file mode 100644 index 0000000..4d7f4fd --- /dev/null +++ b/src/framework/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "framework" +publish = false +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +color-eyre.workspace = true +crossterm.workspace = true +ratatui = { workspace = true, features = ["unstable-widget-ref"] } diff --git a/src/framework/src/effects.rs b/src/framework/src/effects.rs new file mode 100644 index 0000000..a788f92 --- /dev/null +++ b/src/framework/src/effects.rs @@ -0,0 +1,20 @@ +// Actor responsible of processing side effects sent by the update actor. +use color_eyre::Result; +use std::sync::mpsc::Receiver; +use std::sync::mpsc::Sender; + +pub fn run(effects_fn: F, rx: Receiver<(M, Eff)>, tx: Sender) -> Result<()> +where + Msg: Send + Sync + 'static, + F: Fn(&M, Eff) -> Result>, +{ + loop { + let Ok((model, effect)) = rx.recv() else { + return Ok(()); + }; + + if let Some(msg) = effects_fn(&model, effect)? { + tx.send(msg)?; + } + } +} diff --git a/src/events/actor.rs b/src/framework/src/events.rs similarity index 52% rename from src/events/actor.rs rename to src/framework/src/events.rs index 80de30a..ba6e12c 100644 --- a/src/events/actor.rs +++ b/src/framework/src/events.rs @@ -1,12 +1,14 @@ //! Actor responsible of reading terminal input events. -use crate::model::Message; use color_eyre::Result; use crossterm::event; use std::sync::mpsc::Sender; -pub fn run(tx: Sender) -> Result<()> { +pub fn run(tx: Sender) -> Result<()> +where + M: From + Sync + Send + 'static, +{ loop { - let message = Message::from(event::read()?); + let message = M::from(event::read()?); tx.send(message)?; } } diff --git a/src/framework/src/lib.rs b/src/framework/src/lib.rs new file mode 100644 index 0000000..1a3d536 --- /dev/null +++ b/src/framework/src/lib.rs @@ -0,0 +1,82 @@ +//! This library implements an Elm-like framework based on Ratatui +//! That will allow the users to build TUI applications by just providing +//! an Update function, a View function, a terminal and a model. +//! +use color_eyre::Report; +use color_eyre::Result; +use std::{ + sync::mpsc::{Sender, channel}, + thread, +}; +pub use update::Update; +pub use view::View; + +mod effects; +mod events; +mod update; +mod view; + +pub fn start( + model: M, + update_fn: UF, + view_fn: VF, + effects_fn: EF, +) -> Result<(), Report> +where + M: Clone + Send + Sync + 'static, + Eff: Send + Sync + 'static, + Msg: From + Sync + Send + 'static, + UF: Fn(M, Msg) -> Result> + Send + Sync + 'static, + VF: Fn(&M) -> Result + Send + Sync + 'static, + EF: Fn(&M, Eff) -> Result> + 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. + // + let model_1 = model.clone(); + spawn_thread( + || view::run(model_1, terminal, view_fn, view_rx), + shutdown_tx.clone(), + ); + + spawn_thread( + || update::run(model, update_fn, update_rx, view_tx, effects_tx), + shutdown_tx.clone(), + ); + + let effects_update_tx = update_tx.clone(); + spawn_thread( + || effects::run(effects_fn, effects_rx, effects_update_tx), + shutdown_tx.clone(), + ); + + spawn_thread(|| events::run(update_tx), shutdown_tx.clone()); + + let result = shutdown_rx.recv(); + + ratatui::restore(); + + result? +} + +fn spawn_thread(callback: F, shutdown: Sender>) -> thread::JoinHandle<()> +where + F: FnOnce() -> Result<()>, + F: Send + 'static, +{ + thread::spawn(move || { + let result = callback(); + let _ = shutdown.send(result); + }) +} diff --git a/src/framework/src/update.rs b/src/framework/src/update.rs new file mode 100644 index 0000000..848ea1c --- /dev/null +++ b/src/framework/src/update.rs @@ -0,0 +1,46 @@ +//! Actor responsible of maintaining the state of the application. +//! Other actors can query the state of the model or send updates. +use color_eyre::{Report, Result}; +use std::sync::mpsc::{Receiver, Sender}; + +pub enum Update { + Exit, + Next(M), + NextWithEffect(M, E), +} + +pub fn run( + mut model: M, + update_fn: F, + rx: Receiver, + view_tx: Sender, + effects_tx: Sender<(M, Eff)>, +) -> Result<()> +where + F: Fn(M, Msg) -> Result, Report>, + Eff: Sync + Send + 'static, + M: Clone + Sync + Send + 'static, +{ + loop { + let Ok(msg) = rx.recv() else { + return Ok(()); + }; + + let update = update_fn(model, msg)?; + + let (new_model, effect) = match update { + Update::Exit => return Ok(()), + Update::Next(new_model) => (new_model, None), + Update::NextWithEffect(new_model, effect) => (new_model, Some(effect)), + }; + + if let Some(effect) = effect { + effects_tx.send((new_model.clone(), effect))?; + } + + // Send the updated version of the model + view_tx.send(new_model.clone())?; + + model = new_model; + } +} diff --git a/src/framework/src/view.rs b/src/framework/src/view.rs new file mode 100644 index 0000000..d057d4e --- /dev/null +++ b/src/framework/src/view.rs @@ -0,0 +1,42 @@ +// Actor responsible of rendering the model into the terminal. +use color_eyre::Result; +use ratatui::DefaultTerminal; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; +use std::sync::mpsc::Receiver; + +pub struct View(Box); + +impl View { + pub fn new(widget: impl WidgetRef + 'static) -> Self { + Self(Box::new(widget)) + } +} + +impl Widget for View { + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) { + self.0.render_ref(area, buf); + } +} + +pub fn run( + mut model: M, + mut terminal: DefaultTerminal, + view_fn: F, + rx: Receiver, +) -> Result<()> +where + F: Fn(&M) -> Result, +{ + loop { + let widget = view_fn(&model)?; + + terminal.draw(|frame| frame.render_widget(widget, frame.area()))?; + + let Ok(new_model) = rx.recv() else { + return Ok(()); + }; + + model = new_model; + } +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index c0f31b8..0000000 --- a/src/main.rs +++ /dev/null @@ -1,55 +0,0 @@ -use color_eyre::Result; -use model::{Message, Model}; -use std::{ - sync::mpsc::{Sender, channel}, - thread, -}; - -mod events; -mod model; -mod update; -mod view; - -fn main() -> Result<()> { - color_eyre::install()?; - 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::(); - - // 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_thread( - || view::actor::run(Model::default(), terminal, view_rx), - shutdown_tx.clone(), - ); - - spawn_thread( - || update::actor::run(Model::default(), update_rx, view_tx), - shutdown_tx.clone(), - ); - - spawn_thread(|| events::actor::run(update_tx), shutdown_tx.clone()); - - let result = shutdown_rx.recv(); - - ratatui::restore(); - - result? -} - -fn spawn_thread(callback: F, shutdown: Sender>) -> thread::JoinHandle<()> -where - F: FnOnce() -> Result<()>, - F: Send + 'static, -{ - thread::spawn(move || { - let result = callback(); - let _ = shutdown.send(result); - }) -} diff --git a/src/update.rs b/src/update.rs deleted file mode 100644 index 5b11548..0000000 --- a/src/update.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod actor; -mod core; diff --git a/src/update/actor.rs b/src/update/actor.rs deleted file mode 100644 index 9ee47af..0000000 --- a/src/update/actor.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Actor responsible of maintaining the state of the application. -//! Other actors can query the state of the model or send updates. -use crate::model::{Effect, Message, Model}; -use color_eyre::Result; -use std::sync::mpsc::{Receiver, Sender}; - -use crate::update::core; - -pub fn run(mut model: Model, rx: Receiver, tx: Sender) -> Result<()> { - loop { - let Ok(msg) = rx.recv() else { - return Ok(()); - }; - - let (new_model, effect) = core::update(model, msg)?; - - // TODO: Run effects in a separate thread - if let Effect::Stop = effect { - return Ok(()); - } - - // Send the updated version of the model - tx.send(new_model.clone())?; - - model = new_model; - } -} diff --git a/src/update/core.rs b/src/update/core.rs deleted file mode 100644 index f6bc9a9..0000000 --- a/src/update/core.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Elm-like update function. -//! -//! Given the current state (model) and an incoming message from the outside world, -//! return a new state and a side effect. -use crate::model::{Effect, Message, Model}; -use color_eyre::Result; - -pub fn update(model: Model, msg: Message) -> Result<(Model, Effect)> { - match msg { - Message::Exit => Ok((model, Effect::Stop)), - Message::NoOp => Ok((model, Effect::Continue)), - Message::IncCounter => Ok((Model::increment_counter(model), Effect::Continue)), - Message::DecCounter => Ok((Model::decrement_counter(model), Effect::Continue)), - } -} diff --git a/src/view.rs b/src/view.rs deleted file mode 100644 index 5b11548..0000000 --- a/src/view.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod actor; -mod core; diff --git a/src/view/actor.rs b/src/view/actor.rs deleted file mode 100644 index c3a8b75..0000000 --- a/src/view/actor.rs +++ /dev/null @@ -1,21 +0,0 @@ -// Actor responsible of rendering the model into the terminal. -// Here we can configure how many frames per second we want to render. -use crate::model::Model; -use crate::view::core; -use color_eyre::Result; -use ratatui::DefaultTerminal; -use std::sync::mpsc::Receiver; - -pub fn run(mut model: Model, mut terminal: DefaultTerminal, rx: Receiver) -> Result<()> { - loop { - let widget = core::view(&model)?; - - terminal.draw(|frame| frame.render_widget(widget, frame.area()))?; - - let Ok(new_model) = rx.recv() else { - return Ok(()); - }; - - model = new_model; - } -} diff --git a/src/view/core.rs b/src/view/core.rs deleted file mode 100644 index 350b529..0000000 --- a/src/view/core.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Elm-like View function. -//! -//! Given the current state (read-only), return a drawable widget. -use crate::model::Model; -use color_eyre::Result; -use ratatui::{ - style::Stylize, - text::Line, - widgets::{Block, Paragraph, Widget}, -}; - -pub fn view(model: &Model) -> Result { - let counter = model.counter; - - let title = Line::from("Ratatui Actor-based Counter") - .bold() - .blue() - .centered(); - - let text = format!( - r#"Counter TUI! - -Counter: {counter} - -Press `Esc`, `Ctrl-C` or `q` to stop running."# - ); - - let widget = Paragraph::new(text) - .block(Block::bordered().title(title)) - .centered(); - - Ok(widget) -}