[refactor] Build next version of teatui (#8)

This pull request introduces significant improvements to the TeaTui framework, focusing on error handling, functional purity, and codebase simplification. Most notably, it removes the dependency on `color-eyre`, introduces custom error types for each actor (effects, events, update, view), and updates the API to encourage pure functions and more idiomatic Rust error handling. The example app and documentation are also updated to reflect these changes.

**Framework error handling and API improvements:**

* Introduced custom error types (`EffectsError`, `EventLoopError`, `UpdateError`, `ViewError`, and unified `ProgramError`) for each actor, replacing the use of `color-eyre` and providing clearer, structured error reporting throughout the framework. [[1]](diffhunk://#diff-1a8ddf10086f54de37be278b04f140fea42a9dd9314ebed509a03510b34a3043L2-R24) [[2]](diffhunk://#diff-dd43168709f1ba547c9dbe210d659222793384f540898dfa4159d3d28447ea59L2-R14) [[3]](diffhunk://#diff-717c070f88e33e75d62e17bcfb063657cc2cbba2198d8faf77fb3855206ba036L2-R51) [[4]](diffhunk://#diff-348239946b2211ffea21e1282dde8299ee0e5484616b8b2da9c97589719531e4L2-R23) [[5]](diffhunk://#diff-eaa72a947a5cb60aaed20788a77b8c3b94606a2b4bd9218fa2bbeeba0a98f726L34-R62)
* Updated the core `start` function to accept an `init_fn` initializer, and to use the new error types in its signature and internal logic, ensuring all errors are properly propagated and handled. [[1]](diffhunk://#diff-eaa72a947a5cb60aaed20788a77b8c3b94606a2b4bd9218fa2bbeeba0a98f726L61-R95) [[2]](diffhunk://#diff-eaa72a947a5cb60aaed20788a77b8c3b94606a2b4bd9218fa2bbeeba0a98f726L88-R157)
* Refactored the `Update` enum and related logic to use a single variant (`Next(M, Option<E>)`) for state transitions and side effects, simplifying the update pattern and removing the need for multiple variants.

**Example application and dependency updates:**

* Updated the counter example to remove `color-eyre`, use the new error types, and conform to the new pure function API (no more `Result` returns from user functions). [[1]](diffhunk://#diff-d1e739029fa7deef7dec36d183f1097a4ed266898520ddae449cff9c91f65a96L1-R10) [[2]](diffhunk://#diff-d1e739029fa7deef7dec36d183f1097a4ed266898520ddae449cff9c91f65a96L96-R111) [[3]](diffhunk://#diff-d1e739029fa7deef7dec36d183f1097a4ed266898520ddae449cff9c91f65a96L128-R129) [[4]](diffhunk://#diff-d1e739029fa7deef7dec36d183f1097a4ed266898520ddae449cff9c91f65a96R38-R42)
* Updated dependencies in `Cargo.toml` files: removed `color-eyre`, bumped `ratatui` version, and added `thiserror` for error handling. [[1]](diffhunk://#diff-2e9d962a08321605940b5a657135052fbcef87b5e360662bb527c96d9a615542L11-R12) [[2]](diffhunk://#diff-c6fd35fe7c3e8bcfcf9e60f261503d791b528405f92b6ba99f7272e91939d861L9) [[3]](diffhunk://#diff-1cd61e14e7d516bb58a3b2607848174d38c432631899f34dbc88076158f3bf52L10-R12)

**Documentation:**

* Added a comprehensive project overview and architecture description for TeaTui in `AGENTS.md`, explaining the functional philosophy, actor model, and usage guidelines.
This commit is contained in:
Víctor Martínez 2026-01-29 13:19:46 +01:00 committed by GitHub
parent 29a1ecb5f3
commit dec5c76bfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1153 additions and 511 deletions

41
AGENTS.md Normal file
View file

@ -0,0 +1,41 @@
# TeaTui: Agent Context & Project Overview
## Project Summary
**TeaTui** (Tea = The Elm Architecture) is an experimental Rust framework for building Terminal User Interfaces (TUIs) on top of [Ratatui](https://github.com/ratatui/ratatui). It reproduces the **Elm Architecture** (Model-Update-View) to provide a pure functional development experience in Rust.
## Tech Stack & Environment
* **Language**: Rust (Edition **2024**).
* **Workspace Structure**: The project is organized as a workspace containing the core `teatui` library and an `examples` directory.
* **Core Dependencies**:
* `ratatui`: Used for terminal rendering.
* `crossterm`: Handles terminal backend and event polling.
* `thiserror`: Utilized for structured, descriptive error handling across the runtime.
## Design Philosophy
* **Functional Purity**: The goal is to build TUI applications in a pure-functional style, completely removing the usage of `&mut` and procedural state management.
* **Minimal Mutability**: Mutability is discouraged in user-provided code; state transitions occur by returning new instances of the Model.
* **Message Passing as Side Effects**: Communication between internal processes is strictly handled via `std::sync::mpsc` channels. Message passing is considered the "highest form of side effect" in the design.
## Core Architecture
The framework operates through four concurrent actors (threads) that manage the application lifecycle:
1. **Model**: A single type representing the entire application state.
2. **View Actor**: Renders the Model into the terminal. It takes a pure function `&Model -> Widget` and redraws only when the Model changes.
3. **Update Actor**: Manages state transitions. It takes the current Model and an incoming Message, returning an `Update` enum (either `Update::Exit` or `Update::Next(NewModel, Option<Effect>)`).
4. **Effects Actor**: Processes side effects (e.g., IO) returned by the Update actor. It can optionally return a Message to trigger further state updates.
5. **Event Actor**: Translates raw terminal events (via `crossterm`) into application-specific Messages.
## Key Types & Signatures
* **`Update<M, E>`**: Enum signaling whether to exit or continue with a new state and optional effect.
* **`update(model: M, msg: Msg) -> Update<M, E>`**: The core logic for state changes.
* **`view(model: &M) -> Widget`**: The rendering logic.
* **`effects(model: &M, effect: E) -> Option<Msg>`**: The side-effect handler.
## Development Guide
When extending the framework or building apps with it, ensure that `update` and `view` functions remain pure. Any operation involving the "outside world" (files, network, etc.) must be modeled as an `Effect` and handled within the `effects` actor.

1357
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,5 @@ license = "MIT"
edition = "2024"
[workspace.dependencies]
color-eyre = "0.6.3"
crossterm = "0.29.0"
ratatui = { version = "0.29.0", features = ["unstable-widget-ref"] }
ratatui = { version = "0.30" }

View file

@ -6,7 +6,6 @@ edition.workspace = true
authors.workspace = true
[dependencies]
color-eyre.workspace = true
crossterm.workspace = true
ratatui.workspace = true
teatui = { path = "../../teatui" }

View file

@ -1,16 +1,13 @@
use color_eyre::Result;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
style::Stylize,
text::Line,
widgets::{Block, Paragraph},
};
use teatui::{Update, View};
use teatui::{ProgramError, update::Update};
fn main() -> Result<()> {
color_eyre::install()?;
let result = teatui::start(Model::default(), update, view, run_effects);
result
fn main() -> Result<(), ProgramError<Model, Message, Effect>> {
teatui::start(|| (Model::default(), None), update, view, run_effects)
}
/// Defines the state of the application
@ -38,9 +35,11 @@ impl Model {
}
/// Possible side effects to execute
#[derive(Debug)]
pub enum Effect {}
/// Messages that represent a change of state in the application
#[derive(Debug)]
pub enum Message {
IncCounter,
DecCounter,
@ -93,23 +92,23 @@ impl From<crossterm::event::Event> for Message {
///
/// Given the current state (model) and an incoming message from the outside world,
/// return the next updated state
pub fn update(model: Model, msg: Message) -> Result<Update<Model, Effect>> {
pub fn update(model: Model, msg: Message) -> 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))),
Message::Exit => Update::Exit,
Message::NoOp => Update::Next(model, None),
Message::IncCounter => Update::Next(Model::increment_counter(model), None),
Message::DecCounter => Update::Next(Model::decrement_counter(model), None),
}
}
pub fn run_effects(_model: &Model, _effect: Effect) -> Result<Option<Message>> {
Ok(None)
pub fn run_effects(_model: &Model, _effect: Effect) -> Option<Message> {
None
}
/// Elm-like View function.
///
/// Given the current state (read-only), return a drawable widget.
pub fn view(model: &Model) -> Result<View> {
pub fn view(model: &Model) -> Paragraph<'static> {
let counter = model.counter;
let title = Line::from("Ratatui Actor-based Counter")
@ -125,9 +124,7 @@ Counter: {counter}
Press `Esc`, `Ctrl-C` or `q` to stop running."#
);
let widget = Paragraph::new(text)
Paragraph::new(text)
.block(Block::bordered().title(title))
.centered();
Ok(View::new(widget))
.centered()
}

View file

@ -7,6 +7,6 @@ authors.workspace = true
edition.workspace = true
[dependencies]
color-eyre.workspace = true
crossterm.workspace = true
ratatui.workspace = true
thiserror = "2"

View file

@ -1,23 +1,27 @@
//! 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;
use std::sync::mpsc::{Receiver, SendError, Sender};
#[derive(thiserror::Error, Debug)]
pub enum EffectsError<M> {
#[error("Failed to send message to update process")]
MessageSend(#[from] SendError<M>),
}
pub(crate) fn run<M, Msg, Eff, F>(
effects_fn: F,
rx: Receiver<(M, Eff)>,
tx: Sender<Msg>,
) -> Result<()>
) -> Result<(), EffectsError<Msg>>
where
Msg: Send + Sync + 'static,
F: Fn(&M, Eff) -> Result<Option<Msg>>,
F: Fn(&M, Eff) -> Option<Msg>,
{
loop {
let Ok((model, effect)) = rx.recv() else {
return Ok(());
};
if let Some(msg) = effects_fn(&model, effect)? {
if let Some(msg) = effects_fn(&model, effect) {
tx.send(msg)?;
}
}

View file

@ -1,9 +1,17 @@
//! Actor responsible of reading terminal input events.
use color_eyre::Result;
use crossterm::event;
use std::sync::mpsc::Sender;
use std::fmt::Debug;
use std::sync::mpsc::{SendError, Sender};
pub(crate) fn run<M>(tx: Sender<M>) -> Result<()>
#[derive(thiserror::Error, Debug)]
pub enum EventLoopError<M> {
#[error("Failed to send message to update process")]
MessageSend(#[from] SendError<M>),
#[error("Failed to read crossterm event")]
EventRead(#[from] std::io::Error),
}
pub(crate) fn run<M>(tx: Sender<M>) -> Result<(), EventLoopError<M>>
where
M: From<crossterm::event::Event> + Sync + Send + 'static,
{

View file

@ -31,20 +31,35 @@
//! ### Examples
//!
//! You can find a folder with example projects in the [examples](https://github.com/JasterV/teatui/tree/main/examples) folder.
use color_eyre::Report;
use color_eyre::Result;
use std::{
sync::mpsc::{Sender, channel},
thread,
};
use effects::EffectsError;
use events::EventLoopError;
use ratatui::widgets::Widget;
use std::fmt::Debug;
use std::{sync::mpsc::channel, thread};
use update::{Update, UpdateError};
use view::ViewError;
pub use update::Update;
pub use view::View;
pub mod effects;
pub mod events;
pub mod update;
pub mod view;
mod effects;
mod events;
mod update;
mod view;
#[derive(thiserror::Error, Debug)]
pub enum ProgramError<M, Msg, Eff>
where
Eff: Send + Sync + 'static,
{
#[error("The update process crashed: '{0}'")]
UpdateError(UpdateError<M, Eff>),
#[error("The effects process crashed: '{0}'")]
EffectsError(EffectsError<Msg>),
#[error("The view process crashed: '{0}'")]
ViewError(ViewError),
#[error("The event loop error crashed: '{0}'")]
EventLoopError(EventLoopError<Msg>),
#[error("Couldn't gracefully shutdown the program")]
GracefulShutdownError,
}
/// Starts the runtime which manages all the internal
/// processes and message passing.
@ -58,24 +73,26 @@ mod view;
/// - A `view` function, responsible for constructing the view from the model.
///
/// - An `effects` function responsible for handling side effects.
pub fn start<M, Msg, Eff, UF, VF, EF>(
model: M,
pub fn start<M, Msg, Eff, W, IF, UF, VF, EF>(
init_fn: IF,
update_fn: UF,
view_fn: VF,
effects_fn: EF,
) -> Result<(), Report>
) -> Result<(), ProgramError<M, Msg, Eff>>
where
M: Clone + Send + Sync + 'static,
Eff: Send + Sync + 'static,
Eff: Debug + 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,
W: Widget,
IF: Fn() -> (M, Option<Eff>) + Send + Sync + 'static,
UF: Fn(M, Msg) -> Update<M, Eff> + Send + Sync + 'static,
VF: Fn(&M) -> W + Send + Sync + 'static,
EF: Fn(&M, Eff) -> Option<Msg> + Send + Sync + 'static,
{
let terminal = ratatui::init();
// Channel for signaling when a task completes
let (shutdown_tx, shutdown_rx) = channel::<Result<()>>();
let (shutdown_tx, shutdown_rx) = channel::<Result<(), ProgramError<M, Msg, Eff>>>();
// Channels for inter-thread communication
let (update_tx, update_rx) = channel::<Msg>();
@ -85,40 +102,57 @@ where
// 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(),
);
thread::spawn({
let (model, _) = init_fn();
spawn_thread(
|| update::run(model, update_fn, update_rx, view_tx, effects_tx),
shutdown_tx.clone(),
);
let shutdown_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(),
);
move || {
let result = view::run(model, terminal, view_fn, view_rx)
.map_err(|err| ProgramError::ViewError(err));
spawn_thread(|| events::run(update_tx), shutdown_tx.clone());
let _ = shutdown_tx.send(result);
}
});
let result = shutdown_rx.recv();
thread::spawn({
let shutdown_tx = shutdown_tx.clone();
let (model, effect) = init_fn();
move || {
let result = update::run(model, effect, update_fn, update_rx, view_tx, effects_tx)
.map_err(|err| ProgramError::UpdateError(err));
let _ = shutdown_tx.send(result);
}
});
thread::spawn({
let update_tx = update_tx.clone();
let shutdown_tx = shutdown_tx.clone();
move || {
let result = effects::run(effects_fn, effects_rx, update_tx)
.map_err(|err| ProgramError::EffectsError(err));
let _ = shutdown_tx.send(result);
}
});
thread::spawn({
let shutdown_tx = shutdown_tx.clone();
move || {
let result = events::run(update_tx).map_err(|err| ProgramError::EventLoopError(err));
let _ = shutdown_tx.send(result);
}
});
let result = shutdown_rx.recv().ok();
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);
})
match result {
Some(result) => result,
None => Err(ProgramError::GracefulShutdownError),
}
}

View file

@ -1,43 +1,54 @@
//! Actor responsible of maintaining the state of the application.
use color_eyre::{Report, Result};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::mpsc::{Receiver, SendError, Sender};
/// Tells the runtime what to do with the previous message.
///
/// If `Update::Exit` is returned, the program will exit.
///
/// If `Update::Next(M)` is returned, the view will be rendered with the new model.
///
/// If `Update::NextWithEffect` is returned, the view will be rendered with the new model and a side effect will be executed.
/// If `Update::Next(M, Option<E>)` is returned, the view will be rendered with the new model and a side effect might be executed.
pub enum Update<M, E> {
Exit,
Next(M),
NextWithEffect(M, E),
Next(M, Option<E>),
}
#[derive(thiserror::Error, Debug)]
pub enum UpdateError<M, Eff>
where
Eff: Send + Sync + 'static,
{
#[error("Failed to send message to effects handler process")]
EffectSend(#[from] SendError<(M, Eff)>),
#[error("Failed to send message to the view process")]
ViewSend(#[from] SendError<M>),
}
pub(crate) fn run<M, Msg, Eff, F>(
mut model: M,
initial_effect: Option<Eff>,
update_fn: F,
rx: Receiver<Msg>,
view_tx: Sender<M>,
effects_tx: Sender<(M, Eff)>,
) -> Result<()>
) -> Result<(), UpdateError<M, Eff>>
where
F: Fn(M, Msg) -> Result<Update<M, Eff>, Report>,
F: Fn(M, Msg) -> Update<M, Eff>,
Eff: Sync + Send + 'static,
M: Clone + Sync + Send + 'static,
{
if let Some(effect) = initial_effect {
effects_tx.send((model.clone(), effect))?;
}
loop {
let Ok(msg) = rx.recv() else {
return Ok(());
};
let update = update_fn(model, msg)?;
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)),
Update::Next(new_model, effect) => (new_model, effect),
};
// Send the new model to the view

View file

@ -1,38 +1,26 @@
//! 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;
/// A thin wrapper around a `ratatui` WidgetRef.
/// It is guaranteed that it will always be possible
/// to construct it from a Widget.
pub struct View(Box<dyn WidgetRef>);
impl View {
pub fn new(widget: impl WidgetRef + 'static) -> Self {
Self(Box::new(widget))
}
#[derive(thiserror::Error, Debug)]
pub enum ViewError {
#[error("Failed to render a widget into the terminal")]
RenderError(#[from] std::io::Error),
}
impl Widget for View {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) {
self.0.render_ref(area, buf);
}
}
pub(crate) fn run<M, F>(
pub(crate) fn run<M, F, W>(
mut model: M,
mut terminal: DefaultTerminal,
view_fn: F,
rx: Receiver<M>,
) -> Result<()>
) -> Result<(), ViewError>
where
F: Fn(&M) -> Result<View>,
W: Widget,
F: Fn(&M) -> W,
{
loop {
let widget = view_fn(&model)?;
let widget = view_fn(&model);
terminal.draw(|frame| frame.render_widget(widget, frame.area()))?;