# ExRatatui
[](https://hex.pm/packages/ex_ratatui)
[](https://hexdocs.pm/ex_ratatui)
[](https://github.com/mcass19/ex_ratatui/actions/workflows/ci.yml)
[](https://github.com/mcass19/ex_ratatui/blob/main/LICENSE)
Elixir bindings for the Rust [ratatui](https://ratatui.rs) terminal UI library, via [Rustler](https://github.com/rustler-beam/rustler) NIFs.
Build rich terminal UIs in Elixir with ratatui's layout engine, widget library, and styling system — without blocking the BEAM.
## Features
- 5 built-in widgets: Paragraph, Block, List, Table, Gauge
- Constraint-based layout engine (percentage, length, min, max, ratio)
- Non-blocking keyboard, mouse, and resize event polling
- **OTP-supervised TUI apps** via `ExRatatui.App` behaviour with LiveView-inspired callbacks
- Full color support: named, RGB, and 256-color indexed
- Text modifiers: bold, italic, underlined, and more
- 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.exs` | `mix run examples/counter.exs` | Interactive counter with key events |
| `counter_app.exs` | `mix run examples/counter_app.exs` | Counter using `ExRatatui.App` behaviour |
| `task_manager.exs` | `mix run examples/task_manager.exs` | Full task manager with all widgets |
| `task_manager/` | See [README](examples/task_manager/README.md) | Supervised Ecto + SQLite CRUD app |
## Installation
Add `ex_ratatui` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:ex_ratatui, "~> 0.1"}
]
end
```
Then fetch and compile:
```sh
mix deps.get && mix compile
```
A precompiled NIF binary for your platform will be downloaded automatically.
### Prerequisites
- Elixir 1.17+
Precompiled NIF binaries are available for Linux, macOS, and Windows (x86_64 and aarch64). No Rust toolchain needed.
To compile from source instead, install the [Rust toolchain](https://rustup.rs/) and set:
```sh
export EX_RATATUI_BUILD=true
```
## Quick Start
```elixir
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.Paragraph
ExRatatui.run(fn ->
{w, h} = ExRatatui.terminal_size()
paragraph = %Paragraph{
text: "Hello from ExRatatui!",
style: %Style{fg: :green, modifiers: [:bold]},
alignment: :center
}
ExRatatui.draw([{paragraph, %Rect{x: 0, y: 0, width: w, height: h}}])
# Wait for a keypress, then exit
ExRatatui.poll_event(5000)
end)
```
Run it with `mix run examples/hello_world.exs`.
## OTP App Behaviour
For supervised TUI applications, use the `ExRatatui.App` behaviour — a LiveView-inspired callback interface that manages the terminal lifecycle under OTP:
```elixir
defmodule MyApp.TUI do
use ExRatatui.App
@impl true
def mount(_opts) do
{:ok, %{count: 0}}
end
@impl true
def render(state, frame) do
alias ExRatatui.Widgets.Paragraph
alias ExRatatui.Layout.Rect
widget = %Paragraph{text: "Count: #{state.count}"}
rect = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
[{widget, rect}]
end
@impl true
def handle_event(%ExRatatui.Event.Key{code: "q"}, state) do
{:stop, state}
end
def handle_event(%ExRatatui.Event.Key{code: "up"}, state) do
{:noreply, %{state | count: state.count + 1}}
end
def handle_event(_event, state) do
{:noreply, state}
end
end
```
Add it to your supervision tree:
```elixir
children = [{MyApp.TUI, []}]
Supervisor.start_link(children, strategy: :one_for_one)
```
### Callbacks
| Callback | Description |
|----------|-------------|
| `mount/1` | Called once on startup. Return `{:ok, initial_state}` |
| `render/2` | Called after every state change. Receives state and `%Frame{}` with terminal dimensions. Return `[{widget, rect}]` |
| `handle_event/2` | Called on terminal events. Return `{:noreply, state}` or `{:stop, state}` |
| `handle_info/2` | Called for non-terminal messages (e.g., PubSub). Optional — defaults to `{:noreply, state}` |
See the [task_manager example](examples/task_manager/) for a full Ecto-backed app using this behaviour.
## How It Works
ExRatatui bridges Elixir and Rust through [Rustler](https://github.com/rustler-beam/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_event` NIF runs on BEAM's DirtyIo scheduler, so event polling never blocks normal Elixir processes.
- **Terminal state:** Managed in Rust via a global mutex supporting two backends — a real crossterm terminal and a headless test backend for CI.
- **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](https://github.com/philss/rustler_precompiled) so users don't need the Rust toolchain.
## Widgets
### Paragraph
Text display with alignment, wrapping, and scrolling.
```elixir
%Paragraph{
text: "Hello, world!\nSecond line.",
style: %Style{fg: :cyan, modifiers: [:bold]},
alignment: :center,
wrap: true
}
```
### Block
Container with borders and title. Can wrap any other widget via the `:block` field.
```elixir
%Block{
title: "My Panel",
borders: [:all],
border_type: :rounded,
border_style: %Style{fg: :blue}
}
# Compose with other widgets:
%Paragraph{
text: "Inside a box",
block: %Block{title: "Title", borders: [:all]}
}
```
### List
Selectable list with highlight support.
```elixir
%List{
items: ["Elixir", "Rust", "Haskell"],
highlight_style: %Style{fg: :yellow, modifiers: [:bold]},
highlight_symbol: " > ",
selected: 0,
block: %Block{title: " Languages ", borders: [:all]}
}
```
### Table
Table with headers, rows, and column width constraints.
```elixir
%Table{
rows: [["Alice", "30"], ["Bob", "25"]],
header: ["Name", "Age"],
widths: [{:length, 15}, {:length, 10}],
highlight_style: %Style{fg: :yellow},
selected: 0
}
```
### Gauge
Progress bar.
```elixir
%Gauge{
ratio: 0.75,
label: "75%",
gauge_style: %Style{fg: :green}
}
```
## Layout
Split areas into sub-regions using constraints:
```elixir
alias ExRatatui.Layout
alias ExRatatui.Layout.Rect
area = %Rect{x: 0, y: 0, width: 80, height: 24}
# Three-row layout: header, body, footer
[header, body, footer] = Layout.split(area, :vertical, [
{:length, 3},
{:min, 0},
{:length, 1}
])
# Split body into sidebar + main
[sidebar, main] = Layout.split(body, :horizontal, [
{:percentage, 30},
{:percentage, 70}
])
```
Constraint types: `{:percentage, n}`, `{:length, n}`, `{:min, n}`, `{:max, n}`, `{:ratio, num, den}`.
## Events
Poll for keyboard, mouse, and resize events without blocking the BEAM:
```elixir
case ExRatatui.poll_event(100) do
%Event.Key{code: "q", kind: "press"} ->
:quit
%Event.Key{code: "up", kind: "press"} ->
:move_up
%Event.Key{code: "j", kind: "press", modifiers: ["ctrl"]} ->
:ctrl_j
%Event.Resize{width: w, height: h} ->
{:resized, w, h}
nil ->
:timeout
end
```
## Styles
```elixir
# Named colors
%Style{fg: :green, bg: :black}
# RGB
%Style{fg: {:rgb, 255, 100, 0}}
# 256-color indexed
%Style{fg: {:indexed, 42}}
# Modifiers
%Style{modifiers: [:bold, :italic, :underlined]}
```
## Testing
ExRatatui includes a headless test backend for CI-friendly rendering verification:
```elixir
test "renders a paragraph" do
:ok = ExRatatui.init_test_terminal(40, 10)
paragraph = %Paragraph{text: "Hello!"}
:ok = ExRatatui.draw([{paragraph, %Rect{x: 0, y: 0, width: 40, height: 10}}])
content = ExRatatui.get_buffer_content()
assert content =~ "Hello!"
end
```
## Development
```sh
git clone https://github.com/mcass19/ex_ratatui.git
cd ex_ratatui
mix deps.get
mix test
```
To compile the NIF from source, install the [Rust toolchain](https://rustup.rs/) and set `EX_RATATUI_BUILD=true`.
## License
MIT — see [LICENSE](LICENSE) for details.