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

Example Run Description
hello_world.exsmix run examples/hello_world.exs Minimal paragraph display
counter.exsmix run examples/counter.exs Interactive counter with key events
counter_app.exsmix run examples/counter_app.exs Counter using ExRatatui.App behaviour
reducer_counter_app.exsmix run examples/reducer_counter_app.exs Counter using the reducer runtime with subscriptions
system_monitor.exsmix run examples/system_monitor.exs Linux system dashboard: CPU, memory, disk, network, BEAM stats (Linux/Nerves only). Also runs over SSH and Erlang distribution (see below).
widget_showcase.exsmix run examples/widget_showcase.exs Interactive showcase: tabs, progress bars, checkboxes, text input, scrollable logs
task_manager.exsmix run examples/task_manager.exs Full task manager with tabs, table, scrollbar, line gauge, and more
chat_interface.exsmix run examples/chat_interface.exs AI chat interface: markdown, textarea, throbber, popup, slash commands
task_manager/ See README Supervised Ecto + SQLite CRUD app. Also runs over SSH, multiple clients share one DB

Try an example over SSH

mix run --no-halt examples/system_monitor.exs --ssh
# in another terminal:
ssh demo@localhost -p 2222      # password: demo

Try an example over Erlang Distribution

# Terminal 1 — start the app node
elixir --sname app --cookie demo -S mix run --no-halt examples/system_monitor.exs --distributed

# Terminal 2 — attach from another node
iex --sname local --cookie demo -S mix
iex> ExRatatui.Distributed.attach(:"app@hostname", SystemMonitor)

Built with ExRatatui

Installation

Add ex_ratatui to your dependencies in mix.exs:

def deps do
  [
    {:ex_ratatui, "~> 0.7"}
  ]
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.

Learning Path

New to ExRatatui? Follow this progression:

  1. Run an examplemix run examples/hello_world.exs to see it work
  2. Read Building UIs — widgets, layout, styles, and events
  3. Read Callback Runtime — build a supervised OTP app with ExRatatui.App
  4. Try the countermix run examples/counter_app.exs to see callbacks in action
  5. (Optional) Read Reducer Runtime for async commands and subscriptions
  6. Deploy remotely — read the SSH or Distribution guide

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/1init/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: :sshExRatatui.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
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
Building UIs Widgets, layout, styles, and events — everything for render/2
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

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 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.

Testing

ExRatatui includes a headless test backend for CI-friendly rendering verification. Each test terminal is independent, and test_mode disables live terminal input polling so async: true tests do not race ambient TTY events:

test "renders a paragraph" do
  terminal = ExRatatui.init_test_terminal(40, 10)

  paragraph = %Paragraph{text: "Hello!"}
  :ok = ExRatatui.draw(terminal, [{paragraph, %Rect{x: 0, y: 0, width: 40, height: 10}}])

  content = ExRatatui.get_buffer_content(terminal)
  assert content =~ "Hello!"
end

For supervised apps started under test_mode, use ExRatatui.Runtime.inject_event/2 to drive input deterministically:

{:ok, pid} = MyApp.TUI.start_link(name: nil, test_mode: {40, 10})

event = %ExRatatui.Event.Key{code: "q", modifiers: [], kind: "press"}

:ok = ExRatatui.Runtime.inject_event(pid, event)

Troubleshooting

Terminal looks garbled or colors are wrong Make sure your terminal emulator supports 256-color or true color. Most modern terminals (iTerm2, Ghostty, Alacritty, Windows Terminal, Kitty) work out of the box. If using tmux or screen, set TERM=xterm-256color.

SSH client hangs or shows no output Connect with PTY allocation forced: ssh -t user@host -p 2222. Without -t, most SSH clients don't allocate a pseudo-terminal, and the TUI has nowhere to render. See the SSH guide for details.

mix run examples/... exits immediately Make sure you're not piping or redirecting stdin. The TUI needs an interactive terminal to poll events. If running in a non-interactive context, use --no-halt for daemon-mode examples (SSH, distributed).

Tests fail with "terminal_init_failed" This happens when a test tries to start a real terminal without a TTY (common in CI or when backgrounding). Use test_mode: {width, height} to start a headless test backend instead.

Debugging rendering issues Use the headless test backend to inspect buffer contents:

terminal = ExRatatui.init_test_terminal(80, 24)
ExRatatui.draw(terminal, [{widget, rect}])
IO.puts(ExRatatui.get_buffer_content(terminal))

For supervised apps, use ExRatatui.Runtime.snapshot/1 to inspect runtime state and ExRatatui.Runtime.enable_trace/2 to capture state transitions.

Contributing

Contributions are welcome! See CONTRIBUTING.md for development setup and PR guidelines.

License

MIT — see LICENSE for details.