ExRatatui
Elixir bindings for the Rust ratatui terminal UI library, via Rustler NIFs.
Build rich terminal UIs in Elixir with ratatui's layout engine, widget library, and styling system without blocking the BEAM.
Features
- 21 built-in widgets (and counting!): Paragraph, Block, List, Table, Gauge, LineGauge, BarChart, Sparkline, Calendar, Canvas, Chart, Tabs, Scrollbar, Checkbox, TextInput, Clear, Markdown, Textarea, Throbber, Popup, WidgetList
- Constraint-based layout engine (percentage, length, min, max, ratio)
- Non-blocking keyboard, mouse, and resize event polling
-
OTP-supervised TUI apps: via
ExRatatui.Appbehaviour with LiveView-inspired callbacks -
Reducer runtime: for command/subscription driven apps via
use ExRatatui.App, runtime: :reducer -
Built-in SSH transport: serve any
ExRatatui.Appas a remote TUI, standalone or undernerves_ssh - Erlang distribution transport: attach to a remote TUI over Erlang distribution with zero NIF on the app node
- Full color support: named, RGB, and 256-color indexed
- Text modifiers: bold, italic, underlined, and more
-
Rich text on text-bearing widget fields (
Paragraph.text,List.items,Tablecells,Tabs.titles,Block.title): per-span colors and modifiers viaExRatatui.Text.Span/Line -
Custom widgets in pure Elixir via the
ExRatatui.Widgetprotocol: compose primitives into reusable composite widgets without touching Rust -
Focus management for multi-panel apps via
ExRatatui.Focus: declare a ring of focusable IDs, cycle with Tab/Shift+Tab, dispatch keystrokes to the active widget - Headless test backend for CI-friendly rendering verification
- Precompiled NIF binaries: no Rust toolchain needed
- Runs on BEAM's DirtyIo scheduler: never blocks your processes
Examples
| Example | Run | Description |
|---|---|---|
hello_world.exs | mix run examples/hello_world.exs | Minimal paragraph display |
counter_app.exs | mix run examples/counter_app.exs |
Counter using ExRatatui.App behaviour |
The full catalog (system monitor, chat interface, task manager, Ecto-backed CRUD, and more — plus SSH and Erlang-distribution one-liners) lives in examples/README.md.
Built with ExRatatui
- ash_tui — Interactive terminal explorer for Ash domains, resources, attributes, actions, and more.
- bb_tui — Proposal terminal-based dashboard for Beam Bots robots.
- switchyard — Full-featured reducer runtime workbench exercising command batching, async effects, subscription reconciliation, runtime snapshots, distributed attach, and row-scrolled WidgetList.
- nerves_ex_ratatui_example — Example Nerves project with three TUIs (system monitor, LED control, and a reducer-runtime system monitor) on embedded hardware, reachable over SSH subsystems and Erlang distribution.
- phoenix_ex_ratatui_example — Example Phoenix project with two TUIs (callback and reducer runtime) served over SSH and Erlang distribution alongside a public LiveView chat room, sharing PubSub between the browser and the terminal.
- ... yours? Open a PR! Plenty of ideas to explore in awesome-ratatui.
Installation
Add ex_ratatui to your dependencies in mix.exs:
def deps do
[
{:ex_ratatui, "~> 0.8"}
]
endThen fetch and compile:
mix deps.get && mix compile
A precompiled NIF binary for your platform will be downloaded automatically. The native library itself is loaded lazily on first use, so compiling a project that depends on ex_ratatui does not require the NIF to be loaded into the compiler VM.
Prerequisites
- Elixir 1.17+
Precompiled NIF binaries are available for Linux (x86_64, aarch64, armv6/hf, riscv64), macOS (x86_64, aarch64), and Windows (x86_64). No Rust toolchain needed.
To compile from source instead, install the Rust toolchain and set:
export EX_RATATUI_BUILD=trueQuick Start
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
ExRatatui.run(fn terminal ->
{w, h} = ExRatatui.terminal_size()
paragraph = %Paragraph{
text: "Hello from ExRatatui!\n\nPress any key to exit.",
style: %Style{fg: :green, modifiers: [:bold]},
alignment: :center,
block: %Block{
title: " Hello World ",
borders: [:all],
border_type: :rounded,
border_style: %Style{fg: :cyan}
}
}
ExRatatui.draw(terminal, [{paragraph, %Rect{x: 0, y: 0, width: w, height: h}}])
# Wait for a keypress, then exit
ExRatatui.poll_event(60_000)
end)
Try the examples for more, e.g. mix run examples/hello_world.exs.
New here? The Getting Started guide builds a supervised todo app from mix new to a working TUI.
Choosing a Runtime
ExRatatui offers two runtime modes for supervised apps. Both are transport-agnostic — the same module works over local terminal, SSH, or Erlang distribution without changes.
| Callback Runtime | Reducer Runtime | |
|---|---|---|
| Opt-in | use ExRatatui.App (default) | use ExRatatui.App, runtime: :reducer |
| Entry point | mount/1 | init/1 |
| Events | handle_event/2 + handle_info/2 |
Single update/2 receives {:event, _} and {:info, _} |
| Side effects | Direct (send, spawn, etc.) |
First-class Command primitives (message, send_after, async, batch) |
| Timers |
Manual Process.send_after/3 |
Declarative Subscription with auto-reconciliation |
| Tracing | Not built-in |
Built-in via ExRatatui.Runtime |
| Best for | Straightforward interactive TUIs | Apps with async I/O, structured effects, or complex state machines |
Choosing a Transport
All transports serve the same ExRatatui.App module — switch by changing a single option.
| Local (default) | SSH | Erlang Distribution | |
|---|---|---|---|
| Opt-in | Automatic | transport: :ssh | ExRatatui.Distributed.attach/3 |
| NIF required on | App node | App node (daemon) | Client node only |
| Multi-client | No (one terminal) | Yes (isolated per connection) | Yes (isolated per connection) |
| Auth | N/A | Password, public key, or custom | Erlang cookie |
| Best for | Local dev, Nerves console | Remote admin TUIs, Phoenix SSH | Headless nodes, cross-architecture |
| Session isolation | N/A | Full (each client gets own state) | Full (each client gets own state) |
| Network | N/A | TCP (SSH protocol) | Erlang distribution protocol |
Guides
| Guide | Description |
|---|---|
| Getting Started |
Walk-through from mix new to a supervised TUI — the place to start if you're new |
| Building UIs |
Widgets, layout, styles, rich text, and events — everything for render/2 |
| Callback Runtime |
OTP-supervised apps with mount, render, handle_event, and handle_info callbacks |
| Reducer Runtime |
Elm-style apps with init, update, subscriptions, commands, and runtime inspection |
| Custom Widgets |
Compose primitives into reusable widgets via the ExRatatui.Widget protocol |
| State Machine Patterns | Multi-screen apps, modals, and conditional UI without the tangle |
| Testing |
Headless backend, test_mode, inject_event, and assertion patterns |
| Debugging | Runtime.snapshot, tracing, buffer inspection, and common errors |
| Performance |
Render-loop tuning, render?: false, large trees, async effects |
| Running TUIs over SSH |
Serve any app as a remote TUI over SSH, standalone or under nerves_ssh |
| Running TUIs over Erlang Distribution | Drive a TUI from a remote BEAM node with zero NIF on the app side |
| Widgets Cheatsheet | One-page reference with every struct and its key fields |
How It Works
ExRatatui bridges Elixir and Rust through Rustler NIFs (Native Implemented Functions):
Elixir structs -> encode to maps -> Rust NIF -> decode to ratatui types -> render to terminal
Terminal events -> Rust NIF (DirtyIo) -> encode to tuples -> Elixir Event structs- Rendering: Elixir widget structs are encoded as string-keyed maps, passed across the NIF boundary, and decoded into ratatui widget types for rendering.
- Events: The
poll_eventNIF runs on BEAM's DirtyIo scheduler, so event polling never blocks normal Elixir processes. - Terminal state: Each process holds its own terminal reference via Rust ResourceArc, supporting two backends — a real crossterm terminal and a headless test backend for CI. The terminal is automatically restored when the reference is garbage collected.
- Layout: Ratatui's constraint-based layout engine is exposed directly, computing split rectangles on the Rust side and returning them as Elixir tuples.
Precompiled binaries are provided via rustler_precompiled so users don't need the Rust toolchain.
Process Architecture
Each transport builds on the same internal Server, which owns the render loop and dispatches to your ExRatatui.App callbacks:
Local transport:
Supervisor
└── Server (GenServer)
├── owns terminal reference (NIF)
├── polls events on DirtyIo scheduler
└── calls your mount/render/handle_event
SSH transport:
Supervisor
└── SSH.Daemon (GenServer, wraps :ssh.daemon)
└── per client:
SSH channel (:ssh_server_channel)
├── owns Session (in-memory terminal)
├── parses ANSI input → events
└── Server (GenServer)
└── calls your mount/render/handle_event
Distributed transport:
App node Client node
├── Distributed.Listener └── Distributed.Client (GenServer)
│ └── DynamicSupervisor ├── owns terminal reference (NIF)
│ └── per client: ├── polls events locally
│ Server (GenServer) └── sends events → Server
│ └── sends widgets → Client receives widgets ← Server
└── No NIF needed here
All transports provide full session isolation — each connected client gets its own Server process with independent state.
For writing tests see the Testing guide; for runtime introspection and common errors see Debugging.
Contributing
Contributions are welcome! See CONTRIBUTING.md for development setup and PR guidelines.
License
MIT — see LICENSE for details.