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 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);
|
||||
})
|
||||
}
|
||||
|
|
|
|||
46
src/model.rs
46
src/model.rs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
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.
|
||||
//!
|
||||
//! 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
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