diff --git a/src/engine.rs b/src/engine.rs deleted file mode 100644 index 22bfe18..0000000 --- a/src/engine.rs +++ /dev/null @@ -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) -} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..5dd69d1 --- /dev/null +++ b/src/events.rs @@ -0,0 +1 @@ +pub mod actor; diff --git a/src/events/actor.rs b/src/events/actor.rs new file mode 100644 index 0000000..80de30a --- /dev/null +++ b/src/events/actor.rs @@ -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) -> Result<()> { + loop { + let message = Message::from(event::read()?); + tx.send(message)?; + } +} diff --git a/src/main.rs b/src/main.rs index bc099f5..c0f31b8 100644 --- a/src/main.rs +++ b/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::>(); + + // Channels for inter-thread communication + let (update_tx, update_rx) = channel::(); + let (view_tx, view_rx) = channel::(); + + // 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(callback: F, shutdown: Sender>) -> thread::JoinHandle<()> +where + F: FnOnce() -> Result<()>, + F: Send + 'static, +{ + thread::spawn(move || { + let result = callback(); + let _ = shutdown.send(result); + }) } diff --git a/src/model.rs b/src/model.rs index d14bfb0..e2de85a 100644 --- a/src/model.rs +++ b/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 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 diff --git a/src/update.rs b/src/update.rs index 8c3be19..5b11548 100644 --- a/src/update.rs +++ b/src/update.rs @@ -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; diff --git a/src/update/actor.rs b/src/update/actor.rs new file mode 100644 index 0000000..9ee47af --- /dev/null +++ b/src/update/actor.rs @@ -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, tx: Sender) -> 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; + } +} diff --git a/src/update/core.rs b/src/update/core.rs new file mode 100644 index 0000000..f6bc9a9 --- /dev/null +++ b/src/update/core.rs @@ -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)), + } +} diff --git a/src/view.rs b/src/view.rs index 1206fa1..5b11548 100644 --- a/src/view.rs +++ b/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 { - 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; diff --git a/src/view/actor.rs b/src/view/actor.rs new file mode 100644 index 0000000..c3a8b75 --- /dev/null +++ b/src/view/actor.rs @@ -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) -> 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; + } +} diff --git a/src/view/core.rs b/src/view/core.rs new file mode 100644 index 0000000..350b529 --- /dev/null +++ b/src/view/core.rs @@ -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 { + 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) +}