refactor: abstracted the actors framework into a separate library

This commit is contained in:
JasterV 2025-08-19 18:14:51 +02:00
parent 1f655c336c
commit 1b792e6ad0
19 changed files with 377 additions and 174 deletions

28
Cargo.lock generated
View file

@ -121,6 +121,16 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "counter"
version = "0.1.0"
dependencies = [
"color-eyre",
"crossterm 0.29.0",
"framework",
"ratatui",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.28.1" version = "0.28.1"
@ -273,6 +283,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "framework"
version = "0.1.0"
dependencies = [
"color-eyre",
"crossterm 0.29.0",
"ratatui",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@ -519,15 +538,6 @@ dependencies = [
"unicode-width 0.2.0", "unicode-width 0.2.0",
] ]
[[package]]
name = "ratatui-playground"
version = "0.1.0"
dependencies = [
"color-eyre",
"crossterm 0.29.0",
"ratatui",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.17" version = "0.5.17"

View file

@ -1,4 +1,8 @@
[package] [workspace]
resolver = "3"
members = ["src/*"]
[workspace.package]
name = "ratatui-playground" name = "ratatui-playground"
version = "0.1.0" version = "0.1.0"
description = "A playground workspace for ratatui" description = "A playground workspace for ratatui"
@ -6,7 +10,7 @@ authors = ["JasterV <49537445+JasterV@users.noreply.github.com>"]
license = "MIT" license = "MIT"
edition = "2024" edition = "2024"
[dependencies] [workspace.dependencies]
color-eyre = "0.6.3" color-eyre = "0.6.3"
crossterm = "0.29.0" crossterm = "0.29.0"
ratatui = "0.29.0" ratatui = "0.29.0"

12
src/counter/Cargo.toml Normal file
View file

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

133
src/counter/src/main.rs Normal file
View file

@ -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<crossterm::event::Event> 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<Update<Model, Effect>> {
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<Option<Message>> {
Ok(None)
}
/// Elm-like View function.
///
/// Given the current state (read-only), return a drawable widget.
pub fn view(model: &Model) -> Result<View> {
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))
}

View file

@ -27,10 +27,7 @@ impl Model {
} }
} }
pub enum Effect { pub enum Effect {}
Stop,
Continue,
}
pub enum Message { pub enum Message {
IncCounter, IncCounter,

View file

@ -1 +0,0 @@
pub mod actor;

11
src/framework/Cargo.toml Normal file
View file

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

View file

@ -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<M, Msg, Eff, F>(effects_fn: F, rx: Receiver<(M, Eff)>, tx: Sender<Msg>) -> Result<()>
where
Msg: Send + Sync + 'static,
F: Fn(&M, Eff) -> Result<Option<Msg>>,
{
loop {
let Ok((model, effect)) = rx.recv() else {
return Ok(());
};
if let Some(msg) = effects_fn(&model, effect)? {
tx.send(msg)?;
}
}
}

View file

@ -1,12 +1,14 @@
//! Actor responsible of reading terminal input events. //! Actor responsible of reading terminal input events.
use crate::model::Message;
use color_eyre::Result; use color_eyre::Result;
use crossterm::event; use crossterm::event;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
pub fn run(tx: Sender<Message>) -> Result<()> { pub fn run<M>(tx: Sender<M>) -> Result<()>
where
M: From<crossterm::event::Event> + Sync + Send + 'static,
{
loop { loop {
let message = Message::from(event::read()?); let message = M::from(event::read()?);
tx.send(message)?; tx.send(message)?;
} }
} }

82
src/framework/src/lib.rs Normal file
View file

@ -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<M, Msg, Eff, UF, VF, EF>(
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<crossterm::event::Event> + Sync + Send + 'static,
UF: Fn(M, Msg) -> Result<Update<M, Eff>> + Send + Sync + 'static,
VF: Fn(&M) -> Result<View> + Send + Sync + 'static,
EF: Fn(&M, Eff) -> Result<Option<Msg>> + Send + Sync + 'static,
{
let terminal = ratatui::init();
// Channel for signaling when a task completes
let (shutdown_tx, shutdown_rx) = channel::<Result<()>>();
// 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.
//
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<F>(callback: F, shutdown: Sender<Result<()>>) -> thread::JoinHandle<()>
where
F: FnOnce() -> Result<()>,
F: Send + 'static,
{
thread::spawn(move || {
let result = callback();
let _ = shutdown.send(result);
})
}

View file

@ -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<M, E> {
Exit,
Next(M),
NextWithEffect(M, E),
}
pub fn run<M, Msg, Eff, F>(
mut model: M,
update_fn: F,
rx: Receiver<Msg>,
view_tx: Sender<M>,
effects_tx: Sender<(M, Eff)>,
) -> Result<()>
where
F: Fn(M, Msg) -> Result<Update<M, Eff>, 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;
}
}

42
src/framework/src/view.rs Normal file
View file

@ -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<dyn WidgetRef>);
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<M, F>(
mut model: M,
mut terminal: DefaultTerminal,
view_fn: F,
rx: Receiver<M>,
) -> Result<()>
where
F: Fn(&M) -> Result<View>,
{
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;
}
}

View file

@ -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::<Result<()>>();
// Channels for inter-thread communication
let (update_tx, update_rx) = channel::<Message>();
let (view_tx, view_rx) = channel::<Model>();
// 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<F>(callback: F, shutdown: Sender<Result<()>>) -> thread::JoinHandle<()>
where
F: FnOnce() -> Result<()>,
F: Send + 'static,
{
thread::spawn(move || {
let result = callback();
let _ = shutdown.send(result);
})
}

View file

@ -1,2 +0,0 @@
pub mod actor;
mod core;

View file

@ -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<Message>, tx: Sender<Model>) -> 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;
}
}

View file

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

View file

@ -1,2 +0,0 @@
pub mod actor;
mod core;

View file

@ -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<Model>) -> 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;
}
}

View file

@ -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<impl Widget> {
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)
}