Compare commits

...

22 commits
0.1.1 ... main

Author SHA1 Message Date
Victor Martinez Montané
26b9b8921f chore: release-plz update (#1)
This is an automated PR generated by [release-plz](https://github.com/MarcoIeni/release-plz) via Woodpecker CI.

Co-authored-by: release-plz-bot <bot@codeberg.org>
Reviewed-on: https://codeberg.org/JasterV/teatui/pulls/1
2026-04-24 14:33:33 +02:00
JasterV
39e0614412 chore: update deps 2026-04-23 09:57:10 +02:00
JasterV
2144832c22 fix: CD steps order 2026-03-07 01:05:56 +01:00
JasterV
dc920ba83c chore: update Cargo.toml info 2026-03-07 01:01:13 +01:00
JasterV
5ceee74b98 chore: use codeberg container registry images 2026-03-07 00:52:11 +01:00
JasterV
badcee4e35 fix CD 2026-03-07 00:40:15 +01:00
JasterV
7996d4e497 add deny.toml 2026-03-07 00:11:18 +01:00
JasterV
5b82c75480 chore: Add Makefile 2026-03-07 00:11:18 +01:00
JasterV
52f6f0f36f feat: migrate to woodpecker 2026-03-07 00:11:18 +01:00
dependabot[bot]
5e31cb2654
build(deps): bump actions/checkout from 5 to 6 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 17:42:16 +01:00
JasterV
4d7ba57f96 feat: add new example for working with lists 2026-01-30 13:09:29 +01:00
JasterV
92118d929e feat: add a new example for working with tabs 2026-01-30 02:21:03 +01:00
JasterV
c24d7762cb update CI 2026-01-29 15:17:40 +01:00
JasterV
29a16f368c release: 0.4.0 2026-01-29 15:15:40 +01:00
JasterV
f32c6e9dd9 feat: provide a new tokio feature for async effects support via tokio 2026-01-29 15:02:14 +01:00
JasterV
2825688c30 chore: update Cargo.toml 2026-01-29 13:25:24 +01:00
JasterV
5e1ec63fa8 [chore] release 0.2.0 2026-01-29 13:22:02 +01:00
Víctor Martínez
dec5c76bfd
[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.
2026-01-29 13:19:46 +01:00
Víctor Martínez
29a1ecb5f3
Merge pull request #4 from JasterV/dependabot/github_actions/actions/checkout-5
build(deps): bump actions/checkout from 4 to 5
2025-09-24 10:14:10 +02:00
Víctor Martínez
5d54cef53a
Merge pull request #5 from JasterV/dependabot/github_actions/clechasseur/rs-clippy-check-5
build(deps): bump clechasseur/rs-clippy-check from 4 to 5
2025-09-24 10:13:48 +02:00
dependabot[bot]
4c5f1ace82
build(deps): bump clechasseur/rs-clippy-check from 4 to 5
Bumps [clechasseur/rs-clippy-check](https://github.com/clechasseur/rs-clippy-check) from 4 to 5.
- [Release notes](https://github.com/clechasseur/rs-clippy-check/releases)
- [Commits](https://github.com/clechasseur/rs-clippy-check/compare/v4...v5)

---
updated-dependencies:
- dependency-name: clechasseur/rs-clippy-check
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 15:35:08 +00:00
dependabot[bot]
e0d3c1a3d2
build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-26 10:07:37 +00:00
27 changed files with 2575 additions and 640 deletions

View file

@ -1,17 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# Maintain dependencies for Cargo
- package-ecosystem: "cargo"
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
# Maintain dependencies for GitHub Actions
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly

View file

@ -1,90 +0,0 @@
name: CI
on:
pull_request:
push:
branches:
- main
- master
- develop
env:
CARGO_TERM_COLOR: always
# ensure that the workflow is only triggered once per PR, subsequent pushes to the PR will cancel
# and restart the workflow. See https://docs.github.com/en/actions/using-jobs/using-concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
fmt:
name: fmt
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: check formatting
run: cargo fmt -- --check
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
clippy:
name: clippy
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Run clippy action
uses: clechasseur/rs-clippy-check@v4
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
doc:
# run docs generation on nightly rather than stable. This enables features like
# https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an
# API be documented as only available in some specific platforms.
name: doc
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
- name: Run cargo doc
run: cargo doc --no-deps --all-features
env:
RUSTDOCFLAGS: --cfg docsrs
test:
runs-on: ${{ matrix.os }}
name: test ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
steps:
# if your project needs OpenSSL, uncomment this to fix Windows builds.
# it's commented out by default as the install command takes 5-10m.
# - run: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append
# if: runner.os == 'Windows'
# - run: vcpkg install openssl:x64-windows-static-md
# if: runner.os == 'Windows'
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
# enable this ci template to run regardless of whether the lockfile is checked in or not
- name: cargo generate-lockfile
if: hashFiles('Cargo.lock') == ''
run: cargo generate-lockfile
- name: cargo test --locked
run: cargo test --locked --all-features --all-targets
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2

View file

@ -1,32 +0,0 @@
name: Publish to crates.io
on:
release:
types: [created]
env:
CARGO_TERM_COLOR: always
jobs:
publish-crate:
name: Publish to crates.io
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Set up Git
run: |
git config --global user.email "49537445+JasterV@users.noreply.github.com"
git config --global user.name "JasterV"
- name: Install cargo-edit
run: |
cargo install cargo-edit
- name: Update crate version
id: update-version
run: |
cargo set-version -p teatui $GITHUB_REF_NAME
- name: Publish crate
run: |
# Publish the crate using the updated version from the previous step
cargo publish -p teatui --allow-dirty --token ${{ secrets.CRATES_IO_TOKEN }}

23
.woodpecker/cd.yml Normal file
View file

@ -0,0 +1,23 @@
when:
event: push
branch: main
depends_on:
- ci
steps:
- name: Release unpublished crates
image: codeberg.org/jasterv/rust-magic-release:latest
pull: true
settings:
token:
from_secret: CODEBERG_TOKEN
crates_io_token:
from_secret: CRATES_IO_TOKEN
- name: Update PR
image: codeberg.org/jasterv/release-plz-update-pr:latest
pull: true
settings:
token:
from_secret: CODEBERG_TOKEN

12
.woodpecker/ci.yml Normal file
View file

@ -0,0 +1,12 @@
when:
- event: [push, pull_request]
branch: main
steps:
lint:
image: codeberg.org/jasterv/rust-ci:1.95
commands:
- cargo make -p ci fmt-check
- cargo make -p ci clippy
- cargo make -p ci deny-check
- cargo make -p ci docs

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.

1641
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,11 +3,13 @@ resolver = "3"
members = ["teatui", "examples/*"]
[workspace.package]
authors = ["JasterV <49537445+JasterV@users.noreply.github.com>"]
license = "MIT"
authors = ["JasterV jasterv@noreply.codeberg.org"]
description = "A pure functional framework to build TUIs build on top of ratatui"
edition = "2024"
homepage = "https://codeberg.org/JasterV/teatui"
license = "MIT"
repository = "https://codeberg.org/JasterV/teatui"
[workspace.dependencies]
color-eyre = "0.6.3"
crossterm = "0.29.0"
ratatui = { version = "0.29.0", features = ["unstable-widget-ref"] }
ratatui = { version = "0.30" }

47
Makefile.toml Normal file
View file

@ -0,0 +1,47 @@
[config]
default_to_workspace = false
[tasks.build]
description = "Build binaries"
command = "cargo"
args = ["build", "--workspace", "--all-features"]
[tasks.clippy]
description = "Runs clippy."
clear = true
command = "cargo"
args = [
"clippy",
"--all-targets",
"--workspace",
"--",
"-D",
"warnings",
"-W",
"clippy::dbg_macro",
]
[tasks.fmt-check]
description = "Runs the cargo rustfmt plugin during CI."
command = "cargo"
args = ["fmt", "--all", "--", "--check"]
[tasks.deny-check]
description = "Runs the cargo deny plugin during CI."
command = "cargo"
args = ["deny", "check"]
[tasks.test]
description = "Runs tests."
clear = true
run_task = { name = ["doc-tests", "nextest"], fork = true }
[tasks.nextest]
description = "Runs tests without dependencies."
command = "cargo"
args = ["nextest", "run", "--no-fail-fast", "${@}"]
[tasks.doc-tests]
description = "Run doc tests"
command = "cargo"
args = ["test", "--doc"]

50
deny.toml Normal file
View file

@ -0,0 +1,50 @@
[graph]
targets = []
all-features = false
no-default-features = false
[output]
feature-depth = 1
[advisories]
version = 2
# The path where the advisory database is cloned/fetched into
db-path = "~/.cargo/advisory-db"
# The url(s) of the advisory databases to use
db-urls = ["https://github.com/rustsec/advisory-db"]
# The lint level for crates that have been yanked from their source registry
yanked = "warn"
ignore = []
[licenses]
allow = ["Unicode-3.0", "Apache-2.0", "MIT", "Zlib"]
confidence-threshold = 0.8
exceptions = []
[licenses.private]
ignore = false
registries = []
[bans]
multiple-versions = "warn"
wildcards = "allow"
highlight = "all"
workspace-default-features = "allow"
external-default-features = "allow"
allow = []
deny = []
skip = []
skip-tree = []
[sources]
unknown-registry = "warn"
unknown-git = "warn"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
[sources.allow-org]
github = []
gitlab = []
bitbucket = []

View file

@ -4,9 +4,9 @@ publish = false
version = "0.0.0"
edition.workspace = true
authors.workspace = true
license.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()
}

13
examples/tabs/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "tabs"
publish = false
version = "0.0.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
crossterm.workspace = true
ratatui.workspace = true
strum = { version = "0.28.0", features = ["derive"] }
teatui = { path = "../../teatui" }

232
examples/tabs/src/main.rs Normal file
View file

@ -0,0 +1,232 @@
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Color, Stylize, palette::tailwind},
symbols,
text::Line,
widgets::{Block, Padding, Paragraph, Tabs, Widget},
};
use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
use teatui::{ProgramError, update::Update};
fn main() -> Result<(), ProgramError<Model, Message, ()>> {
teatui::start(init, update, view, |_, _| None)
}
fn init() -> (Model, Option<()>) {
(Model::default(), None)
}
/// Defines the state of the application
#[derive(Debug, Clone, Default)]
struct Model {
selected_tab: SelectedTab,
}
impl Model {
pub fn next_tab(self) -> Self {
Model {
selected_tab: self.selected_tab.next(),
}
}
pub fn previous_tab(self) -> Self {
Model {
selected_tab: self.selected_tab.previous(),
}
}
}
#[derive(Default, Debug, Clone, Copy, Display, FromRepr, EnumIter)]
enum SelectedTab {
#[default]
#[strum(to_string = "Tab 1")]
Tab1,
#[strum(to_string = "Tab 2")]
Tab2,
#[strum(to_string = "Tab 3")]
Tab3,
#[strum(to_string = "Tab 4")]
Tab4,
}
impl SelectedTab {
/// Get the previous tab, if there is no previous tab return the current tab.
fn previous(self) -> Self {
let current_index: usize = self as usize;
let previous_index = current_index.saturating_sub(1);
Self::from_repr(previous_index).unwrap_or(self)
}
/// Get the next tab, if there is no next tab return the current tab.
fn next(self) -> Self {
let current_index = self as usize;
let next_index = current_index.saturating_add(1);
Self::from_repr(next_index).unwrap_or(self)
}
}
/// Messages that represent a change of state in the application
#[derive(Debug)]
enum Message {
NextTab,
PreviousTab,
Exit,
NoOp,
}
impl From<crossterm::event::Event> for Message {
fn from(value: Event) -> Self {
match value {
Event::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Char('q'),
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::Exit,
Event::Key(KeyEvent {
code: KeyCode::Char('l') | KeyCode::Right,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::NextTab,
Event::Key(KeyEvent {
code: KeyCode::Char('h') | KeyCode::Left,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::PreviousTab,
Event::FocusGained
| Event::FocusLost
| Event::Key(_)
| Event::Mouse(_)
| Event::Paste(_)
| Event::Resize(_, _) => Self::NoOp,
}
}
}
/// Elm-like update function.
///
/// Given the current state (model) and an incoming message from the outside world,
/// return the next updated state
fn update(model: Model, msg: Message) -> Update<Model, ()> {
match msg {
Message::Exit => Update::Exit,
Message::NoOp => Update::Next(model, None),
Message::NextTab => Update::Next(Model::next_tab(model), None),
Message::PreviousTab => Update::Next(Model::previous_tab(model), None),
}
}
/// Elm-like View function.
///
/// Given the current state, return a drawable widget.
fn view(model: Model) -> AppWidget {
AppWidget { model }
}
struct AppWidget {
model: Model,
}
impl Widget for AppWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
use Constraint::{Length, Min};
let vertical = Layout::vertical([Length(1), Min(0), Length(1)]);
let [header_area, inner_area, footer_area] = vertical.areas(area);
let horizontal = Layout::horizontal([Min(0), Length(20)]);
let [tabs_area, title_area] = horizontal.areas(header_area);
"Ratatui Tabs Example".bold().render(title_area, buf);
render_tabs(&self.model.selected_tab, tabs_area, buf);
self.model.selected_tab.render(inner_area, buf);
Line::raw("◄ ► to change tab | Press q to quit")
.centered()
.render(footer_area, buf);
}
}
fn render_tabs(selected_tab: &SelectedTab, area: Rect, buf: &mut Buffer) {
let titles = SelectedTab::iter().map(SelectedTab::title);
let highlight_style = (Color::default(), selected_tab.palette().c700);
let selected_tab_index = (*selected_tab) as usize;
Tabs::new(titles)
.highlight_style(highlight_style)
.select(selected_tab_index)
.padding("", "")
.divider(" ")
.render(area, buf);
}
impl Widget for SelectedTab {
fn render(self, area: Rect, buf: &mut Buffer) {
// in a real app these might be separate widgets
match self {
Self::Tab1 => self.render_tab0(area, buf),
Self::Tab2 => self.render_tab1(area, buf),
Self::Tab3 => self.render_tab2(area, buf),
Self::Tab4 => self.render_tab3(area, buf),
}
}
}
impl SelectedTab {
/// Return tab's name as a styled `Line`
fn title(self) -> Line<'static> {
format!(" {self} ")
.fg(tailwind::SLATE.c200)
.bg(self.palette().c900)
.into()
}
fn render_tab0(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("Hello, World!")
.block(self.block())
.render(area, buf);
}
fn render_tab1(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("Welcome to the Ratatui tabs example!")
.block(self.block())
.render(area, buf);
}
fn render_tab2(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("Look! I'm different than others!")
.block(self.block())
.render(area, buf);
}
fn render_tab3(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("I know, these are some basic changes. But I think you got the main idea.")
.block(self.block())
.render(area, buf);
}
/// A block surrounding the tab's content
fn block(self) -> Block<'static> {
Block::bordered()
.border_set(symbols::border::PROPORTIONAL_TALL)
.padding(Padding::horizontal(1))
.border_style(self.palette().c700)
}
const fn palette(self) -> tailwind::Palette {
match self {
Self::Tab1 => tailwind::BLUE,
Self::Tab2 => tailwind::EMERALD,
Self::Tab3 => tailwind::INDIGO,
Self::Tab4 => tailwind::RED,
}
}
}

View file

@ -0,0 +1,13 @@
[package]
name = "todo_list"
publish = false
version = "0.0.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
crossterm.workspace = true
ratatui.workspace = true
strum = "0.28.0"
teatui = { path = "../../teatui" }

View file

@ -0,0 +1,18 @@
//! # [TeaTui] List example
use crate::model::Model;
use message::Message;
use teatui::ProgramError;
mod message;
mod model;
mod update;
mod view;
fn main() -> Result<(), ProgramError<Model, Message, ()>> {
teatui::start(init, update::update, view::view, |_, _| None)
}
fn init() -> (Model, Option<()>) {
(Model::default(), None)
}

View file

@ -0,0 +1,75 @@
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
#[derive(Debug)]
pub enum Message {
NoOp,
Exit,
SelectNext,
SelectNone,
SelectPrevious,
SelectFirst,
SelectLast,
ToggleStatus,
}
impl From<crossterm::event::Event> for Message {
fn from(value: crossterm::event::Event) -> Self {
match value {
Event::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Char('q'),
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::Exit,
Event::Key(KeyEvent {
code: KeyCode::Char('l') | KeyCode::Char(' ') | KeyCode::Right,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::ToggleStatus,
Event::Key(KeyEvent {
code: KeyCode::Char('h') | KeyCode::Left,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectNone,
Event::Key(KeyEvent {
code: KeyCode::Char('j') | KeyCode::Down,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectNext,
Event::Key(KeyEvent {
code: KeyCode::Char('k') | KeyCode::Up,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectPrevious,
Event::Key(KeyEvent {
code: KeyCode::Char('g') | KeyCode::Home,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectFirst,
Event::Key(KeyEvent {
code: KeyCode::Char('G') | KeyCode::End,
kind: KeyEventKind::Press,
state: _,
modifiers: _,
}) => Self::SelectLast,
Event::FocusGained
| Event::FocusLost
| Event::Key(_)
| Event::Mouse(_)
| Event::Paste(_)
| Event::Resize(_, _) => Self::NoOp,
}
}
}

View file

@ -0,0 +1,364 @@
use ratatui::widgets::ListState;
#[derive(Clone, Debug)]
pub struct Model {
pub items: Vec<TodoItem>,
pub state: ListState,
}
#[derive(Clone, Debug)]
pub struct TodoItem {
pub todo: String,
pub info: String,
pub status: Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Status {
Todo,
Completed,
}
impl Default for Model {
fn default() -> Self {
Model::from_iter([
(
Status::Todo,
"Rewrite everything with Rust!",
"I can't hold my inner voice. He tells me to rewrite the complete universe with Rust",
),
(
Status::Completed,
"Rewrite all of your tui apps with Ratatui",
"Yes, you heard that right. Go and replace your tui with Ratatui.",
),
(
Status::Todo,
"Pet your cat",
"Minnak loves to be pet by you! Don't forget to pet and give some treats!",
),
(
Status::Todo,
"Walk with your dog",
"Max is bored, go walk with him!",
),
(
Status::Completed,
"Pay the bills",
"Pay the train subscription!!!",
),
(
Status::Completed,
"Refactor list example",
"If you see this info that means I completed this task!",
),
(
Status::Todo,
"Implement a neural network in pure Rust",
"Because why use Python when you can have zero-cost abstractions and borrow checker headaches?",
),
(
Status::Todo,
"Buy more coffee",
"The compile times are getting longer, or maybe I'm just getting slower. Either way, caffeine is required.",
),
(
Status::Completed,
"Fix that one annoying lifetime error",
"It only took three days and a sacrifice to the crab god, but it finally compiles!",
),
(
Status::Todo,
"Clean the mechanical keyboard",
"There is a non-zero amount of crumbs between the blue switches. Its affecting my WPM.",
),
(
Status::Todo,
"Read the 'Rustonomicon'",
"Into the dark depths of unsafe Rust we go. May the pointer aliasing rules have mercy.",
),
(
Status::Completed,
"Hydrate",
"Drank a full glass of water. Look at me, practicing self-care while writing low-level code.",
),
(
Status::Todo,
"Argue about memory safety on the internet",
"Someone said C is 'fine.' I must cordially explain why they are mistaken.",
),
(
Status::Todo,
"Organize the .dotfiles",
"Spend 4 hours configuring Neovim instead of actually working on the project.",
),
(
Status::Completed,
"Update dependencies",
"Ran 'cargo update'. Only 14 breaking changes in the ecosystem today. A new record!",
),
(
Status::Todo,
"Research async-trait internals",
"Why is it a macro? Why is life a macro? I need to know.",
),
(
Status::Todo,
"Go to the gym",
"Strong body, strong memory management. Don't let your muscles leak like a C++ string.",
),
(
Status::Completed,
"Explain ownership to a rubber duck",
"The duck didn't get it at first, but after a few squeaks, it finally understood move semantics.",
),
(
Status::Todo,
"Check for compiler updates",
"Is there a new nightly? I need those experimental features for no reason at all.",
),
(
Status::Todo,
"Debug the deadlocked thread",
"It's not a bug, it's just a very dedicated pause in execution.",
),
(
Status::Completed,
"Star every crate I use on GitHub",
"Showing love to the maintainers who keep my 'cargo build' alive.",
),
(
Status::Todo,
"Write a custom proc-macro",
"I want to generate code that generates code. We need to go deeper.",
),
(
Status::Todo,
"Finally learn how Pin works",
"I've read the docs five times. Maybe the sixth time is the charm?",
),
(
Status::Completed,
"Delete the 'node_modules' folder",
"Regained 40GB of disk space. Nature is healing.",
),
(
Status::Todo,
"Optimize the Docker image size",
"From 1.2GB to 10MB using multi-stage builds and Alpine. Efficiency feels good.",
),
(
Status::Todo,
"Fix the CI/CD pipeline",
"It works on my machine, but GitHub Actions thinks otherwise.",
),
(
Status::Completed,
"Replace a 5-line bash script with 200 lines of Rust",
"Was it worth it? Yes. It's type-safe now.",
),
(
Status::Todo,
"Actually write documentation",
"The code is the documentation... said no one ever (and lived).",
),
(
Status::Todo,
"Try a different terminal emulator",
"Is Alacritty faster than Kitty? Let the benchmarking begin.",
),
(
Status::Completed,
"Nap for 20 minutes",
"Brain was stuck in a recursion loop. Had to reboot the system.",
),
(
Status::Todo,
"Configure a status bar for the TWM",
"I need to see my CPU temperature in 16-bit color at all times.",
),
(
Status::Todo,
"Rewrite the parser using Nom",
"Regex is great, but combinators are cooler.",
),
(
Status::Completed,
"Find a cool Ferris sticker",
"My laptop lid still has 2 square inches of empty space.",
),
(
Status::Todo,
"Master Vim motions",
"H, J, K, L... why did I just delete the entire main function?",
),
(
Status::Todo,
"Experiment with WebAssembly",
"Rust in the browser is the future, and the future is now.",
),
(
Status::Completed,
"Add a dark mode to the UI",
"My eyes are no longer burning. Success.",
),
(
Status::Todo,
"Submit a PR to an open-source project",
"Fixing a typo counts as a contribution, right?",
),
(
Status::Todo,
"Explain Cow to a non-programmer",
"No, not the animal. It's 'Clone On Write'. Why are you walking away?",
),
(
Status::Completed,
"Organize the physical desk",
"Found a missing USB drive and three half-empty coffee mugs.",
),
(
Status::Todo,
"Write unit tests for the edge cases",
"What happens if the input is an emoji and a null byte? Let's find out.",
),
(
Status::Todo,
"Learn a new functional programming language",
"Just to see how it feels. Don't worry Rust, I'm not leaving you.",
),
(
Status::Completed,
"Solve a LeetCode hard in Rust",
"The borrow checker was the real final boss.",
),
(
Status::Todo,
"Set up a Raspberry Pi server",
"It will sit in the corner and run a Telegram bot for two days before I forget about it.",
),
(
Status::Todo,
"Refactor the error handling",
"Switching from 'unwrap()' to 'thiserror' because I'm a professional now.",
),
(
Status::Completed,
"Explain the orphan rule to a coworker",
"I think I confused them more, but I feel smarter.",
),
(
Status::Todo,
"Benchmark the hot path",
"Flamegraphs are like modern art, but for nerds.",
),
(
Status::Todo,
"Buy a more ergonomic chair",
"My back is screaming in a language I don't understand.",
),
(
Status::Completed,
"Finally finish this list",
"36 items in total. Now I just have to actually do them.",
),
])
}
}
impl FromIterator<(Status, &'static str, &'static str)> for Model {
fn from_iter<I: IntoIterator<Item = (Status, &'static str, &'static str)>>(iter: I) -> Self {
let items: Vec<_> = iter
.into_iter()
.map(|(status, todo, info)| TodoItem::new(status, todo, info))
.collect();
let state = ListState::default().with_selected(Some(0));
Self { items, state }
}
}
impl TodoItem {
fn new(status: Status, todo: &str, info: &str) -> Self {
Self {
status,
todo: todo.to_string(),
info: info.to_string(),
}
}
}
impl Model {
pub fn selected(&self) -> Option<&TodoItem> {
self.state
.selected()
.and_then(|index| self.items.get(index))
}
pub fn select_none(self) -> Self {
let state = ListState::default().with_selected(None);
Self {
items: self.items,
state,
}
}
pub fn select_first(self) -> Self {
let state = ListState::default().with_selected(Some(0));
Model {
items: self.items,
state,
}
}
pub fn select_last(self) -> Self {
let state = ListState::default().with_selected(Some(self.items.len() - 1));
Model {
items: self.items,
state,
}
}
pub fn select_next(self) -> Self {
match self.state.selected() {
Some(index) => {
let state =
ListState::default().with_selected(Some((index + 1) % self.items.len()));
Model { state, ..self }
}
None => self.select_first(),
}
}
pub fn select_previous(self) -> Self {
match self.state.selected() {
Some(0) => self.select_last(),
Some(index) => {
let state = ListState::default().with_selected(Some(index.saturating_sub(1)));
Model { state, ..self }
}
None => self.select_last(),
}
}
pub fn toggle_status(mut self) -> Self {
let selected_index = self.state.selected();
match selected_index {
Some(index) => {
self.items[index].status = match self.items[index].status {
Status::Todo => Status::Completed,
Status::Completed => Status::Todo,
};
Model { ..self }
}
None => Model { ..self },
}
}
}

View file

@ -0,0 +1,16 @@
use teatui::update::Update;
use crate::{message::Message, model::Model};
pub fn update(model: Model, msg: Message) -> Update<Model, ()> {
match msg {
Message::NoOp => Update::Next(model, None),
Message::Exit => Update::Exit,
Message::SelectNext => Update::Next(model.select_next(), None),
Message::SelectNone => Update::Next(model.select_none(), None),
Message::SelectPrevious => Update::Next(model.select_previous(), None),
Message::SelectFirst => Update::Next(model.select_first(), None),
Message::SelectLast => Update::Next(model.select_last(), None),
Message::ToggleStatus => Update::Next(model.toggle_status(), None),
}
}

View file

@ -0,0 +1,140 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{
Color, Modifier, Style, Stylize,
palette::tailwind::{BLUE, GREEN, SLATE},
},
symbols,
text::Line,
widgets::{
Block, Borders, HighlightSpacing, List, ListItem, Padding, Paragraph, StatefulWidget,
Widget, Wrap,
},
};
use crate::model::{Model, Status, TodoItem};
const TODO_HEADER_STYLE: Style = Style::new().fg(SLATE.c100).bg(BLUE.c800);
const NORMAL_ROW_BG: Color = SLATE.c950;
const ALT_ROW_BG_COLOR: Color = SLATE.c900;
const SELECTED_STYLE: Style = Style::new().bg(SLATE.c800).add_modifier(Modifier::BOLD);
const TEXT_FG_COLOR: Color = SLATE.c200;
const COMPLETED_TEXT_FG_COLOR: Color = GREEN.c500;
pub fn view(model: Model) -> AppWidget {
AppWidget { model }
}
pub struct AppWidget {
model: Model,
}
impl Widget for AppWidget {
fn render(mut self, area: Rect, buf: &mut Buffer) {
let [header_area, main_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(area);
let [list_area, item_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]).areas(main_area);
Paragraph::new("Ratatui List Example")
.bold()
.centered()
.render(header_area, buf);
Paragraph::new("Use ↓↑ to move, ← to unselect, → to change status, g/G to go top/bottom.")
.centered()
.render(footer_area, buf);
self.render_list(list_area, buf);
self.render_selected_item(item_area, buf);
}
}
/// Rendering logic for the app
impl AppWidget {
fn render_list(&mut self, area: Rect, buf: &mut Buffer) {
let block = Block::new()
.title(Line::raw("TODO List").centered())
.borders(Borders::TOP)
.border_set(symbols::border::EMPTY)
.border_style(TODO_HEADER_STYLE)
.bg(NORMAL_ROW_BG);
// Iterate through all elements in the `items` and stylize them.
let items: Vec<ListItem> = self
.model
.items
.iter()
.enumerate()
.map(|(i, todo_item)| {
let color = alternate_colors(i);
ListItem::from(todo_item).bg(color)
})
.collect();
// Create a List from all list items and highlight the currently selected one
let list = List::new(items)
.block(block)
.highlight_style(SELECTED_STYLE)
.highlight_symbol(">")
.highlight_spacing(HighlightSpacing::Always);
// We need to disambiguate this trait method as both `Widget` and `StatefulWidget` share the
// same method name `render`.
StatefulWidget::render(list, area, buf, &mut self.model.state);
}
fn render_selected_item(&self, area: Rect, buf: &mut Buffer) {
// We get the info depending on the item's state.
let info = if let Some(item) = self.model.selected() {
match item.status {
Status::Completed => format!("✓ DONE: {}", item.info),
Status::Todo => format!("☐ TODO: {}", item.info),
}
} else {
"Nothing selected...".to_string()
};
// We show the list item's info under the list in this paragraph
let block = Block::new()
.title(Line::raw("TODO Info").centered())
.borders(Borders::TOP)
.border_set(symbols::border::EMPTY)
.border_style(TODO_HEADER_STYLE)
.bg(NORMAL_ROW_BG)
.padding(Padding::horizontal(1));
// We can now render the item info
Paragraph::new(info)
.block(block)
.fg(TEXT_FG_COLOR)
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
const fn alternate_colors(i: usize) -> Color {
if i.is_multiple_of(2) {
NORMAL_ROW_BG
} else {
ALT_ROW_BG_COLOR
}
}
impl From<&TodoItem> for ListItem<'_> {
fn from(value: &TodoItem) -> Self {
let line = match value.status {
Status::Todo => Line::styled(format!("{}", value.todo), TEXT_FG_COLOR),
Status::Completed => {
Line::styled(format!("{}", value.todo), COMPLETED_TEXT_FG_COLOR)
}
};
ListItem::new(line)
}
}

14
teatui/CHANGELOG.md Normal file
View file

@ -0,0 +1,14 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.4.2](https://codeberg.org/JasterV/teatui/compare/teatui-v0.4.1...teatui-v0.4.2) - 2026-04-23
### Other
- update deps

View file

@ -1,12 +1,17 @@
[package]
name = "teatui"
version = "0.1.0"
version = "0.4.2"
description = "An elm-like abstraction over Ratatui"
license.workspace = true
authors.workspace = true
edition.workspace = true
[features]
default = []
tokio = ["dep:tokio"]
[dependencies]
color-eyre.workspace = true
crossterm.workspace = true
ratatui.workspace = true
thiserror = "2"
tokio = { version = "1", features = ["full"], optional = true }

View file

@ -1,24 +1,68 @@
//! 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};
#[cfg(feature = "tokio")]
use std::future::Future;
#[derive(thiserror::Error, Debug)]
pub enum EffectsError<M> {
#[error("Failed to send message to update process")]
MessageSend(#[from] SendError<M>),
}
#[cfg(not(feature = "tokio"))]
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)?;
}
}
}
#[cfg(feature = "tokio")]
pub(crate) fn run_async<M, Msg, Eff, F, Fut>(
effects_fn: F,
rx: Receiver<(M, Eff)>,
tx: Sender<Msg>,
) -> Result<(), EffectsError<Msg>>
where
M: Send + Sync + 'static,
Msg: Send + Sync + 'static,
Eff: Send + Sync + 'static,
Fut: Future<Output = Option<Msg>> + Send,
F: Fn(M, Eff) -> Fut + Send + Sync + 'static,
{
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to build Tokio reactor for side-effects");
rt.block_on(async {
loop {
let Ok((model, effect)) = rx.recv() else {
break;
};
// We spawn the effect in the tokio reactor so they can run concurrently
let fut = effects_fn(model, effect);
if let Some(msg) = fut.await {
let _ = tx.send(msg);
}
}
});
Ok(())
}

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,67 +73,130 @@ 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,
#[cfg(not(feature = "tokio"))]
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,
{
run_program(init_fn, update_fn, view_fn, move |effects_rx, update_tx| {
effects::run(effects_fn, effects_rx, update_tx)
})
}
/// Starts the runtime with asynchronous (Tokio) side effects.
#[cfg(feature = "tokio")]
pub fn start<M, Msg, Eff, W, IF, UF, VF, EF, Fut>(
init_fn: IF,
update_fn: UF,
view_fn: VF,
effects_fn: EF,
) -> Result<(), ProgramError<M, Msg, Eff>>
where
M: Clone + Send + Sync + 'static,
Eff: Debug + Send + Sync + 'static,
Msg: From<crossterm::event::Event> + Sync + Send + '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) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Option<Msg>> + Send,
{
run_program(init_fn, update_fn, view_fn, move |effects_rx, update_tx| {
effects::run_async(effects_fn, effects_rx, update_tx)
})
}
/// Internal helper to abstract the common actor-spawning logic.
fn run_program<M, Msg, Eff, W, IF, UF, VF, SF>(
init_fn: IF,
update_fn: UF,
view_fn: VF,
effects_fn: SF,
) -> Result<(), ProgramError<M, Msg, Eff>>
where
M: Clone + Send + Sync + 'static,
Eff: Debug + Send + Sync + 'static,
Msg: From<crossterm::event::Event> + Sync + Send + '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,
SF: FnOnce(
std::sync::mpsc::Receiver<(M, Eff)>,
std::sync::mpsc::Sender<Msg>,
) -> Result<(), EffectsError<Msg>>
+ Send
+ Sync
+ 'static,
{
let terminal = ratatui::init();
// Channel for signaling when a task completes
let (shutdown_tx, shutdown_rx) = channel::<Result<()>>();
// Channels for inter-thread communication
let (shutdown_tx, shutdown_rx) = channel::<Result<(), ProgramError<M, Msg, Eff>>>();
let (update_tx, update_rx) = channel::<Msg>();
let (view_tx, view_rx) = channel::<M>();
let (effects_tx, effects_rx) = channel::<(M, Eff)>();
// 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(),
);
// Spawn View Actor
thread::spawn({
let (model, _) = init_fn();
let shutdown_tx = shutdown_tx.clone();
move || {
let result =
view::run(model, terminal, view_fn, view_rx).map_err(ProgramError::ViewError);
let _ = shutdown_tx.send(result);
}
});
spawn_thread(
|| update::run(model, update_fn, update_rx, view_tx, effects_tx),
shutdown_tx.clone(),
);
// Spawn Update Actor
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(ProgramError::UpdateError);
let _ = shutdown_tx.send(result);
}
});
let effects_update_tx = update_tx.clone();
spawn_thread(
|| effects::run(effects_fn, effects_rx, effects_update_tx),
shutdown_tx.clone(),
);
// Spawn Effects Actor
thread::spawn({
let shutdown_tx = shutdown_tx.clone();
let update_tx = update_tx.clone();
spawn_thread(|| events::run(update_tx), shutdown_tx.clone());
move || {
let result = effects_fn(effects_rx, update_tx).map_err(ProgramError::EffectsError);
let result = shutdown_rx.recv();
let _ = shutdown_tx.send(result);
}
});
// Spawn Events Actor
thread::spawn({
let shutdown_tx = shutdown_tx.clone();
move || {
let result = events::run(update_tx).map_err(ProgramError::EventLoopError);
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()))?;