ExRatatui

Hex.pmDocsCILicense

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.

ExRatatui Demo

Features

Examples

ExampleRunDescription
hello_world.exsmix run examples/hello_world.exsMinimal paragraph display
counter_app.exsmix run examples/counter_app.exsCounter 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

Installation

Add ex_ratatui to your dependencies in mix.exs:

def deps do
[
{:ex_ratatui, "~> 0.10"}
]
end

Then 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

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=true

Quick 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 RuntimeReducer Runtime
Opt-inuse ExRatatui.App (default)use ExRatatui.App, runtime: :reducer
Entry pointmount/1init/1
Eventshandle_event/2 + handle_info/2Single update/2 receives {:event, _} and {:info, _}
Side effectsDirect (send, spawn, etc.)First-class Command primitives (message, send_after, async, batch)
TimersManual Process.send_after/3Declarative Subscription with auto-reconciliation
TracingNot built-inBuilt-in via ExRatatui.Runtime
Best forStraightforward interactive TUIsApps 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)SSHErlang Distribution
Opt-inAutomatictransport: :sshExRatatui.Distributed.attach/3
NIF required onApp nodeApp node (daemon)Client node only
Multi-clientNo (one terminal)Yes (isolated per connection)Yes (isolated per connection)
AuthN/APassword, public key, or customErlang cookie
Best forLocal dev, Nerves consoleRemote admin TUIs, Phoenix SSHHeadless nodes, cross-architecture
Session isolationN/AFull (each client gets own state)Full (each client gets own state)
NetworkN/ATCP (SSH protocol)Erlang distribution protocol

Guides

GuideDescription
Getting StartedWalk-through from mix new to a supervised TUI — the place to start if you're new
Building UIsWidgets, layout, styles, rich text, and events — everything for render/2
Callback RuntimeOTP-supervised apps with mount, render, handle_event, and handle_info callbacks
Reducer RuntimeElm-style apps with init, update, subscriptions, commands, and runtime inspection
Custom WidgetsCompose primitives into reusable widgets via the ExRatatui.Widget protocol
State Machine PatternsMulti-screen apps, modals, and conditional UI without the tangle
TestingHeadless backend, test_mode, inject_event, and assertion patterns
DebuggingRuntime.snapshot, tracing, buffer inspection, and common errors
PerformanceRender-loop tuning, render?: false, large trees, async effects
Telemetry:telemetry events for runtime, render, transport, and session — logging, metrics, OpenTelemetry
TransportsCanonical feature matrix — what works where across Local / Session / SSH / Distributed / CellSession
Running TUIs over SSHServe any app as a remote TUI over SSH, standalone or under nerves_ssh
Running TUIs over Erlang DistributionDrive a TUI from a remote BEAM node with zero NIF on the app side
Custom TransportsPlug in your own transport (TCP, Livebook, WebSocket) via the ExRatatui.Transport behaviour
Rendering to Non-Terminal SurfacesUse ExRatatui.CellSession to expose the rendered cell buffer to LiveView, framebuffers, screenshot tools, and other non-ANSI consumers
Paste and ClipboardBracketed paste behaviour, text_input_insert_str/textarea_insert_str helpers, and an OSC 52 copy snippet
Widgets CheatsheetOne-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

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 inputevents
└── 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.