mirror of
https://codeberg.org/JasterV/teatui.git
synced 2026-04-26 18:10:03 +00:00
refactor: abstracted the actors framework into a separate library
This commit is contained in:
parent
1f655c336c
commit
1b792e6ad0
19 changed files with 377 additions and 174 deletions
28
Cargo.lock
generated
28
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
12
src/counter/Cargo.toml
Normal file
12
src/counter/Cargo.toml
Normal 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
133
src/counter/src/main.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -27,10 +27,7 @@ impl Model {
|
|||
}
|
||||
}
|
||||
|
||||
pub enum Effect {
|
||||
Stop,
|
||||
Continue,
|
||||
}
|
||||
pub enum Effect {}
|
||||
|
||||
pub enum Message {
|
||||
IncCounter,
|
||||
|
|
@ -1 +0,0 @@
|
|||
pub mod actor;
|
||||
11
src/framework/Cargo.toml
Normal file
11
src/framework/Cargo.toml
Normal 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"] }
|
||||
20
src/framework/src/effects.rs
Normal file
20
src/framework/src/effects.rs
Normal 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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Message>) -> Result<()> {
|
||||
pub fn run<M>(tx: Sender<M>) -> Result<()>
|
||||
where
|
||||
M: From<crossterm::event::Event> + Sync + Send + 'static,
|
||||
{
|
||||
loop {
|
||||
let message = Message::from(event::read()?);
|
||||
let message = M::from(event::read()?);
|
||||
tx.send(message)?;
|
||||
}
|
||||
}
|
||||
82
src/framework/src/lib.rs
Normal file
82
src/framework/src/lib.rs
Normal 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);
|
||||
})
|
||||
}
|
||||
46
src/framework/src/update.rs
Normal file
46
src/framework/src/update.rs
Normal 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
42
src/framework/src/view.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/main.rs
55
src/main.rs
|
|
@ -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);
|
||||
})
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
pub mod actor;
|
||||
mod core;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
pub mod actor;
|
||||
mod core;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in a new issue