mirror of
https://codeberg.org/JasterV/teatui.git
synced 2026-04-26 18:10:03 +00:00
refactor: truly event-driven functional style
This commit is contained in:
parent
ef45e65784
commit
3dc08c450d
11 changed files with 198 additions and 90 deletions
|
|
@ -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
1
src/events.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod actor;
|
||||||
12
src/events/actor.rs
Normal file
12
src/events/actor.rs
Normal 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)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/main.rs
50
src/main.rs
|
|
@ -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 model;
|
||||||
mod update;
|
mod update;
|
||||||
mod view;
|
mod view;
|
||||||
|
|
||||||
fn main() -> color_eyre::Result<()> {
|
fn main() -> Result<()> {
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
|
|
||||||
let terminal = ratatui::init();
|
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();
|
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);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
src/model.rs
46
src/model.rs
|
|
@ -4,8 +4,28 @@
|
||||||
//! from the outside world and the effects that can be produced.
|
//! from the outside world and the effects that can be produced.
|
||||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Model {}
|
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 {
|
pub enum Effect {
|
||||||
Stop,
|
Stop,
|
||||||
|
|
@ -13,7 +33,9 @@ pub enum Effect {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
ExitKeyPressed,
|
IncCounter,
|
||||||
|
DecCounter,
|
||||||
|
Exit,
|
||||||
NoOp,
|
NoOp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,14 +47,28 @@ impl From<crossterm::event::Event> for Message {
|
||||||
kind: KeyEventKind::Press,
|
kind: KeyEventKind::Press,
|
||||||
state: _,
|
state: _,
|
||||||
modifiers: _,
|
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 {
|
Event::Key(KeyEvent {
|
||||||
code: KeyCode::Char('c') | KeyCode::Char('C'),
|
code: KeyCode::Char('c') | KeyCode::Char('C'),
|
||||||
modifiers: KeyModifiers::CONTROL,
|
modifiers: KeyModifiers::CONTROL,
|
||||||
kind: KeyEventKind::Press,
|
kind: KeyEventKind::Press,
|
||||||
state: _,
|
state: _,
|
||||||
}) => Self::ExitKeyPressed,
|
}) => Self::Exit,
|
||||||
|
|
||||||
Event::FocusGained
|
Event::FocusGained
|
||||||
| Event::FocusLost
|
| Event::FocusLost
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,2 @@
|
||||||
//! Elm-like update function.
|
pub mod actor;
|
||||||
//!
|
mod core;
|
||||||
//! 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)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
27
src/update/actor.rs
Normal file
27
src/update/actor.rs
Normal 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
15
src/update/core.rs
Normal 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/view.rs
29
src/view.rs
|
|
@ -1,27 +1,2 @@
|
||||||
//! Elm-like View function.
|
pub mod actor;
|
||||||
//!
|
mod core;
|
||||||
//! 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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
21
src/view/actor.rs
Normal file
21
src/view/actor.rs
Normal 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
33
src/view/core.rs
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue