akiomik/tears

A simple and elegant TUI framework with The Elm Architecture (TEA) and TCA-style reducer composition

β˜… 9Forks 1RustGitHub β†—Compare
composable-architectureelm-architectureratatuirusttcateaterminaltuitui-framework

README

tears

Crates.io Documentation CI License Rust Version codecov

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.

Features

  • 🎯 Simple & Predictable: Based on The Elm Architecture - easy to reason about and test
  • 🧩 Composable: Split a program into feature reducers with scope, for_each and presented
  • πŸ”„ 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

Installation

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.

Getting Started

Minimal Example

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(())
}

Complete Example

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
}

Architecture

Tears follows The Elm Architecture (TEA) pattern:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  Model  │─────▢│  View  │─────▢│  UI  β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚       β–²                                      β”‚
β”‚       β”‚                                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”‚
β”‚  β”‚  Update  │◀────│   Messages   β”‚           β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚
β”‚       β–²                   β–²                  β”‚
β”‚       β”‚                   β”‚                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”           β”‚
β”‚  β”‚ Commands β”‚      β”‚Subscriptionsβ”‚           β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚
β”‚                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Core Concepts

  • 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

Composing Reducers

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.

Built-in Subscriptions

  • 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, requires ws): Real-time bidirectional communication
  • Query (http::Query, requires http): 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.

Examples

Check out the examples/ directory. They fall into two groups, and each group is named after what a reader arrives knowing.

Application structure

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 input
  • views.rs - Multiple view states with navigation and conditional subscriptions
  • dashboard.rs - Structured state management, with the root owning the wiring
  • dashboard_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

Framework features

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 with install_panic_hook
  • signals.rs - OS signal handling with graceful shutdown (SIGINT, SIGTERM, etc.)
  • command_timeout_retry.rs - Enforcing a Command deadline with timeout and recovering from failures with retry
  • command_cancellation.rs - Cancelling superseded in-flight commands with cancellable/cancellable_with and CancelPolicy
  • websocket.rs - WebSocket echo chat demonstrating real-time communication (requires ws feature)
  • http_todo.rs - HTTP Todo list with Query subscription, Mutation, and cache management (requires http feature)

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 http

Testing Your Application

tears::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 dashboard

tears::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_composed

Repository-wide test conventions live in docs/testing.md.

Optional Features

Tears supports optional features that can be enabled in your Cargo.toml:

WebSocket Support

[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 TLS
    • rustls - Pure Rust TLS with native certificates
    • rustls-tls-webpki-roots - Pure Rust TLS with webpki certificates

HTTP Support

[dependencies]
tears = { version = "0.11", features = ["http"] }
  • http: Enables HTTP Query and Mutation support
    • Query subscription for automatic data fetching with caching
    • Mutation for data modifications (POST, PUT, PATCH, DELETE)
    • QueryClient for cache management and invalidation
    • Design rationale and invariants: RFC 0001: http Module Redesign

Inspiration & Design Philosophy

Tears is inspired by battle-tested architectures:

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

Minimum Supported Rust Version (MSRV)

Tears requires Rust 1.88.0 or later (uses edition 2024).

License

Licensed under the Apache License, Version 2.0. See LICENSE for details.

Contributing

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

Contributors

akiomikdependabot[bot]belltoy

Issues