A simple and elegant TUI framework with The Elm Architecture (TEA) and TCA-style reducer composition.
Built on top of ratatui, Tears provides a clean, type-safe, and functional approach to terminal user interface development.
- π― Simple & Predictable: Based on The Elm Architecture - easy to reason about and test
- π§© Composable: Split a program into feature reducers with
scope,for_eachandpresented - π Async-First: Built-in support for async operations via Commands
- π‘ Subscriptions: Handle terminal events, timers, and custom event sources
- π§ͺ Testable: Pure functions for update logic make testing straightforward
- π Powered by Ratatui: Leverage the full power of the ratatui ecosystem
- π¦ Type-Safe: Leverages Rust's type system for safer TUI applications
Add this to your Cargo.toml:
[dependencies]
tears = "0.11"
ratatui = "0.30"
crossterm = "0.29"
tokio = { version = "1", features = ["full"] }See the Optional Features section for information about enabling ws (WebSocket) and http (HTTP Query/Mutation) features.
A tears application implements the Application trait, which has four required methods (Composing Reducers is the other way to write a program):
use tears::prelude::*;
use ratatui::Frame;
struct App;
enum Message {}
impl Application for App {
type Message = Message; // Your message type
type Flags = (); // Initialization data (use () if none)
// Initialize your app
fn new(_flags: ()) -> (Self, Command<Message>) {
(App, Command::none())
}
// Handle messages and update state
fn update(&mut self, _msg: Message) -> Command<Message> {
Command::none()
}
// Render your UI
fn view(&self, frame: &mut Frame) {
// Use ratatui widgets here
}
// Subscribe to events (keyboard, timers, etc.)
fn subscriptions(&self) -> Vec<Subscription<Message>> {
vec![]
}
}To run your application, create an Runtime and call run():
#[tokio::main]
async fn main() -> Result<()> {
let runtime = Runtime::<App>::new(());
// Setup terminal (see complete example below)
// ...
runtime.run(&mut terminal).await?;
Ok(())
}Here's a simple counter application that increments every second:
use std::num::NonZeroU64;
use color_eyre::eyre::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{Frame, text::Text};
use tears::prelude::*;
use tears::subscription::{terminal::TerminalEvents, time::{Timer, TimerEvent}};
#[derive(Debug, Clone)]
enum Message {
Tick,
Input(Event),
InputError(String),
}
struct Counter {
count: u32,
}
impl Application for Counter {
type Message = Message;
type Flags = ();
fn new(_flags: ()) -> (Self, Command<Message>) {
(Counter { count: 0 }, Command::none())
}
fn update(&mut self, msg: Message) -> Command<Message> {
match msg {
Message::Tick => {
self.count += 1;
Command::none()
}
Message::Input(Event::Key(key)) if key.code == KeyCode::Char('q') => {
Command::quit()
}
Message::InputError(e) => {
eprintln!("Input error: {e}");
Command::quit()
}
_ => Command::none(),
}
}
fn view(&self, frame: &mut Frame) {
let text = Text::raw(format!("Count: {} (Press 'q' to quit)", self.count));
frame.render_widget(text, frame.area());
}
fn subscriptions(&self) -> Vec<Subscription<Message>> {
vec![
Subscription::new(Timer::new(NonZeroU64::new(1000).expect("non-zero"))).map(|timer_msg| {
match timer_msg {
TimerEvent::Tick => Message::Tick,
}
}),
Subscription::new(TerminalEvents::new()).map(|result| match result {
Ok(event) => Message::Input(event),
Err(e) => Message::InputError(e.to_string()),
}),
]
}
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
// Setup terminal
let mut terminal = ratatui::init();
// Restore the terminal on panic before the color_eyre report runs.
// Installed after `color_eyre::install()` so it wraps that hook.
tears::install_panic_hook();
// Run the application
let runtime = Runtime::<Counter>::new(());
let result = runtime.run(&mut terminal).await;
// Restore terminal (normal exit path)
ratatui::restore();
result
}Tears follows The Elm Architecture (TEA) pattern:
ββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β βββββββββββ ββββββββββ ββββββββ β
β β Model βββββββΆβ View βββββββΆβ UI β β
β βββββββββββ ββββββββββ ββββββββ β
β β² β
β β β
β ββββββ΄ββββββ ββββββββββββββββ β
β β Update βββββββ Messages β β
β ββββββββββββ ββββββββββββββββ β
β β² β² β
β β β β
β ββββββ΄ββββββ ββββββββ΄βββββββ β
β β Commands β βSubscriptionsβ β
β ββββββββββββ βββββββββββββββ β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββ
- Model: Your application state
- Message: Events that trigger state changes
- Update: Pure function that processes messages and returns new state + commands
- View: Pure function that renders UI based on current state
- Subscriptions: External event sources (keyboard, timers, network, etc.)
- Commands: Asynchronous side effects that produce messages
A Reducer owns one state transition and the subscriptions that state declares.
scope, for_each and presented compose one under a parent β a sibling
feature, one child per row of a Keyed collection, or an optionally-present
child in a Slot β and into_program closes the stack into a runnable
Program for ProgramRuntime. Every boundary qualifies the identities its
child produces with its own segment, so rows declaring the same subscription or
keying the same command do not collide. The two whose child can leave β
for_each over a Keyed collection and presented over a Slot β
additionally tear a departed child's runs down, so no removal leaks a run;
scope composes one child in place, and that child is always there.
Command::teardown is the manual primitive behind the teardowns for_each
and presented perform, and Command::on_teardown registers a finalizer to
run when a teardown selects the scope it was built at; one built at a scope
nothing tears down never runs.
An Application is run through an adapter over the same kernel, so this is a
way to write a program rather than a second runtime. See
docs/composition.md for when the rewrite is worth it, and
examples/dashboard_composed.rs for
dashboard.rs written the other way.
- Terminal Events (
terminal::TerminalEvents): Keyboard, mouse, and resize events - Timer (
time::Timer): Periodic tick events - Signal (
signal::Signal): OS signal handling (Unix/Windows) - WebSocket (
websocket::WebSocket, requiresws): Real-time bidirectional communication - Query (
http::Query, requireshttp): HTTP data fetching with caching - MockSource (
mock::MockSource): Controllable mock for testing
Create custom subscriptions by implementing the SubscriptionSource trait.
http::Mutation does not implement it: a mutation is a one-off effect,
dispatched from update rather than declared in subscriptions.
Check out the examples/ directory. They fall into two groups,
and each group is named after what a reader arrives knowing.
Named after the application, because someone choosing a structure knows the
shape of their problem and not yet the name of the API. The first three are a
scale, each one the previous grown; the fourth is the third one rewritten, so
read it beside dashboard.rs rather than after it.
counter.rs- A simple counter with timer and keyboard inputviews.rs- Multiple view states with navigation and conditional subscriptionsdashboard.rs- Structured state management, with the root owning the wiringdashboard_composed.rs- The same application with composed reducers: a keyed collection of child reducers, an optionally-present child, automatic scope application and teardown of removed children
Named after the API item they demonstrate, because that is what a reader
arrives looking for. http_todo.rs carries both, as a feature that needs a
real application to be worth showing.
panic_hook.rs- Restoring the terminal on panic withinstall_panic_hooksignals.rs- OS signal handling with graceful shutdown (SIGINT, SIGTERM, etc.)command_timeout_retry.rs- Enforcing aCommanddeadline withtimeoutand recovering from failures withretrycommand_cancellation.rs- Cancelling superseded in-flight commands withcancellable/cancellable_withandCancelPolicywebsocket.rs- WebSocket echo chat demonstrating real-time communication (requireswsfeature)http_todo.rs- HTTP Todo list with Query subscription, Mutation, and cache management (requireshttpfeature)
RetryError/RetryPolicy and CommandId/CancelPolicy are imported explicitly
from tears::command rather than from the crate root or prelude.
Run an example:
cargo run --example counter
cargo run --example views
cargo run --example dashboard
cargo run --example dashboard_composed
cargo run --example panic_hook
cargo run --example signals
cargo run --example command_timeout_retry
cargo run --example command_cancellation
cargo run --example websocket --features ws,rustls
cargo run --example http_todo --features httptears::testing::TestStore drives an Application's update transitions and
command effects synchronously and deterministically, with no wall-clock waiting.
A test constructs the store from the application's flags, scripts messages with
send, moves virtual time with advance, asserts effect output with
receive/receive_matching/receive_quit, and closes the run with finish
(which fails the test if any deliverable output or unfinished effect is left
unaccounted for). Assertions are exhaustive by design.
use tears::testing::TestStore;
let mut store = TestStore::<App>::new(flags);
store.send(some_message);
store.advance(Duration::from_millis(200)); // move a Command::timeout deadline
store.receive_matching(|msg| matches!(msg, Message::Loaded(_)));
store.finish();TestStore is constructed on a plain #[test] (never #[tokio::test]; it owns
its own paused time context) and does not execute subscription sources β it
observes only the declared set via subscription_ids. See the
tears::testing module docs for
the full contract, including deterministic time without TestStore. Worked,
runnable tests ship with these examples:
cargo test --example command_timeout_retry
cargo test --example command_cancellation
cargo test --example dashboardtears::testing::TestDriver
drives the production kernel a pass at a time: the same construction path, the
same runtime-owned tasks, with the test scripting what production decides for
itself. An order the driver establishes is therefore never evidence of a
production order. It takes a Program where TestStore takes an
Application β a composed stack becomes one through into_program, and an
Application becomes one through
tears::reducer::AppProgram,
the adapter the Runtime facade already applies β and it too is written on a
plain #[test], for a reason of its own: turning the executor it owns blocks
the calling thread, and so does dropping it, and Tokio refuses to block a
thread that is already driving tasks. So a #[tokio::test] that builds a
driver in its own body and never drives it still fails, at the drop.
Driving the real kernel is what puts two things within reach. A declared
subscription source runs; and in a composed program, the teardown a boundary
originates when a child leaves runs too, where TestStore has no boundary to
originate one. So reach for TestStore when the assertion is about an
Application's update transitions and command effects, and for TestDriver
when it is about a source running at all, or β in a composed program β a
child's arrival and removal across passes. Not about when a time-gated
source produces, though: that needs a paused runtime the driver cannot be
given, and so a different test shape, the one deterministic time without
TestStore describes in the module docs linked above.
examples/dashboard_composed.rs carries worked TestDriver tests:
cargo test --example dashboard_composedRepository-wide test conventions live in docs/testing.md.
Tears supports optional features that can be enabled in your Cargo.toml:
[dependencies]
tears = { version = "0.11", features = ["ws", "rustls"] }ws: Enables WebSocket subscription support- TLS backends (choose one for
wss://support):native-tls- Platform's native TLSrustls- Pure Rust TLS with native certificatesrustls-tls-webpki-roots- Pure Rust TLS with webpki certificates
[dependencies]
tears = { version = "0.11", features = ["http"] }http: Enables HTTP Query and Mutation supportQuerysubscription for automatic data fetching with cachingMutationfor data modifications (POST, PUT, PATCH, DELETE)QueryClientfor cache management and invalidation- Design rationale and invariants: RFC 0001:
httpModule Redesign
Tears is inspired by battle-tested architectures:
- Elm: The original Elm Architecture
- The Composable Architecture (TCA): Reducer composition, effect cancellation IDs, and the exhaustive testing surface
- iced: Rust GUI framework (v0.12 design)
- Bubble Tea: Go TUI framework with TEA
The framework is designed with these principles:
- Simplicity First: Minimal and easy-to-understand API
- Thin Framework: Minimal abstraction over ratatui - you have full control
- Type Safety: Leverage Rust's type system for correctness
Tears requires Rust 1.88.0 or later (uses edition 2024).
Licensed under the Apache License, Version 2.0. See LICENSE for details.
Contributions are welcome! Please feel free to submit issues or pull requests.
CONTRIBUTING.md has the conventions a change here follows and points at the documents that hold the rest β the RFC process, the API guidelines, the testing conventions and the release procedure.
Built with β€οΈ using ratatui