refactor: truly event-driven functional style

This commit is contained in:
JasterV 2025-08-19 10:41:22 +02:00
parent ef45e65784
commit 3dc08c450d
11 changed files with 198 additions and 90 deletions

View file

@ -1,39 +0,0 @@
//! This module represents the obscure "side effects" layer of our TUI application.
//!
//! It is called engine because it implements the gears that deal with the outside world.
//!
//! Thanks to this layer, we can work on the "update" and "render" without having to worry about side effects.
//!
//! It knows how to read events from the outside world and how to talk to the terminal.
//!
use crate::{
model::{Effect, Message, Model},
update::update,
view::view,
};
use color_eyre::Result;
use crossterm::event;
use ratatui::DefaultTerminal;
pub fn run(model: Model, mut terminal: DefaultTerminal) -> Result<()> {
// 1. Render a widget from the current state
let widget = view(&model)?;
terminal.draw(|frame| frame.render_widget(widget, frame.area()))?;
// 2. Read terminal events
let message = Message::from(event::read()?);
// 3. Update the model based on the received events
let (model, effect) = update(model, message)?;
// 4. Run side effects if any
if let Effect::Stop = effect {
// If side effects were to become more complex,
// I'd separate the implementation in a separate module.
return Ok(());
}
// 5. Move to the next iteration
run(model, terminal)
}

1
src/events.rs Normal file
View file

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

12
src/events/actor.rs Normal file
View file

@ -0,0 +1,12 @@
//! 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<()> {
loop {
let message = Message::from(event::read()?);
tx.send(message)?;
}
}

View file

@ -1,17 +1,55 @@
use model::Model;
use color_eyre::Result;
use model::{Message, Model};
use std::{
sync::mpsc::{Sender, channel},
thread,
};
mod engine;
mod events;
mod model;
mod update;
mod view;
fn main() -> color_eyre::Result<()> {
fn main() -> Result<()> {
color_eyre::install()?;
let terminal = ratatui::init();
engine::run(Model::default(), terminal)?;
// 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();
Ok(())
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

@ -4,8 +4,28 @@
//! from the outside world and the effects that can be produced.
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
#[derive(Debug, Default)]
pub struct Model {}
#[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 }
}
}
pub enum Effect {
Stop,
@ -13,7 +33,9 @@ pub enum Effect {
}
pub enum Message {
ExitKeyPressed,
IncCounter,
DecCounter,
Exit,
NoOp,
}
@ -25,14 +47,28 @@ impl From<crossterm::event::Event> for Message {
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::ExitKeyPressed,
}) => 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::ExitKeyPressed,
}) => Self::Exit,
Event::FocusGained
| Event::FocusLost

View file

@ -1,13 +1,2 @@
//! 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::ExitKeyPressed => Ok((model, Effect::Stop)),
Message::NoOp => Ok((model, Effect::Continue)),
}
}
pub mod actor;
mod core;

27
src/update/actor.rs Normal file
View file

@ -0,0 +1,27 @@
//! 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;
}
}

15
src/update/core.rs Normal file
View file

@ -0,0 +1,15 @@
//! 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,27 +1,2 @@
//! 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 title = Line::from("Ratatui Simple Template")
.bold()
.blue()
.centered();
let text = "Hello, Ratatui!\n\n\
Created using https://github.com/ratatui/templates\n\
Press `Esc`, `Ctrl-C` or `q` to stop running.";
let widget = Paragraph::new(text)
.block(Block::bordered().title(title))
.centered();
Ok(widget)
}
pub mod actor;
mod core;

21
src/view/actor.rs Normal file
View file

@ -0,0 +1,21 @@
// 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;
}
}

33
src/view/core.rs Normal file
View file

@ -0,0 +1,33 @@
//! 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)
}