ExRatatui - Elixir bindings for the Rust ratatui terminal UI library

ExRatatui lets you cook up rich terminal UIs in Elixir, powered by Rust’s ratatui via Rustler NIFs. Build interactive terminal applications that run under OTP supervision — without blocking the BEAM.

Why?

I wanted to build terminal UIs in Elixir with the same ergonomics we’re used to from LiveView. The existing options in the ecosystem are either stale (ratatouille hasn’t been updated in years) or focused on CLI output rather than full-screen interactive apps. Meanwhile, ratatui is one of the most actively maintained TUI libraries in any language — so bridging it to Elixir felt like the right approach.

What it looks like

The ExRatatui.App behaviour uses LiveView-inspired callbacks — mount/1, render/2, handle_event/2, and handle_info/2:

defmodule MyCounter do
  use ExRatatui.App

  @impl true
  def mount(_opts), do: {:ok, %{count: 0}}

  @impl true
  def render(state, frame) do
    alias ExRatatui.Widgets.Paragraph
    alias ExRatatui.Layout.Rect

    widget = %Paragraph{text: "Count: #{state.count}"}
    [{widget, %Rect{x: 0, y: 0, width: frame.width, height: frame.height}}]
  end

  @impl true
  def handle_event(%ExRatatui.Event.Key{code: "up"}, state),
    do: {:noreply, %{state | count: state.count + 1}}

  def handle_event(%ExRatatui.Event.Key{code: "q"}, state),
    do: {:stop, state}

  def handle_event(_event, state),
    do: {:noreply, state}
end

# Add to your supervision tree
children = [{MyCounter, []}]
Supervisor.start_link(children, strategy: :one_for_one)

Features

  • 5 widgets (so far): Paragraph, Block, List, Table, Gauge — with Block composition on all of them
  • Constraint-based layout engine — split areas by percentage, length, min, max, or ratio
  • Non-blocking event polling — keyboard, mouse, and resize events on BEAM’s DirtyIo scheduler
  • OTP-supervised apps via ExRatatui.App behaviour
  • Full color support — 17 named colors, RGB, and 256-color indexed
  • Headless test backend — render to an in-memory buffer for CI-friendly testing
  • Precompiled NIF binaries for Linux, macOS, and Windows — no Rust toolchain needed

Installation

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

Precompiled binaries are downloaded automatically. No Rust toolchain required.

Examples

The repo includes several examples you can run directly:

  • mix run examples/hello_world.exs — minimal paragraph display
  • mix run examples/counter.exs — interactive counter with key events
  • mix run examples/counter_app.exs — counter using the App behaviour
  • mix run examples/system_monitor.exs — system dashboard (CPU, memory, disk, network, BEAM stats)
  • mix run examples/task_manager.exs — full task manager using all widgets
  • examples/task_manager/ — a complete supervised Ecto + SQLite CRUD app with a TUI interface

What’s next

More widgets (Tabs, Sparkline, BarChart, Scrollbar), rich text primitives (mixed-style spans within a single widget), custom widgets. Beyond that — a theming system, periodic handle_tick callbacks, viewport modes for inline rendering, single-binary distribution via Burrito, etc etc.

The precompiled NIFs already target ARM and RISC-V, so running TUI apps on Nerves devices over SSH should be a natural fit? I haven’t played with it yet!

The issues list is the place to go — ratatui has a huge surface area, so there’s a lot of room to grow.

Contributions are very welcome!!!

Links

I’d love to hear what people think and what you’d want to build with it.

21 Likes

Love this! After using IBM AS400 at work I had the thought of a TUI over SSH and this library looks like it fits the bill (DynamicSupervisor with Erlang’s built in ssh server?)

Ran into a minor crash with the task_manager.exs example, but it was an easy fix and I’ve submitted a PR :smiley:

2 Likes

Quick update! ExRatatui is now at v0.5 with more widgets. And more in the makings…

Some projects built with it are starting to appear:

  • AshTui — Interactive terminal explorer for Ash domains, resources, attributes, actions, and relationships. Two-panel UI with search, tabs, scrollbar, and relationship navigation. Run mix ash.tui and you’re in.
  • Nerves ExRatatui Example — System monitor and LED control TUI running on a Raspberry Pi. Renders directly to the HDMI console. Works on RPi Zero, 3, 4, and 5.

Feedback and contributions very welcome!

3 Likes

Looks fantastic. Looking forward to the charting aspects being added especially the line charts.

1 Like

0.6 is out! Headline is a built-in SSH transport that lets you serve any ExRatatui.App module as a remote TUI over OTP :ssh. A single daemon hands each connected client its own isolated session; multiple clients can attach to the same app at the same time without stepping on each other.

What it looks like

The simplest shape. Drop the daemon straight into a supervision tree:

children = [
  {MyApp.TUI,
   transport: :ssh,
   port: 2222,
   auto_host_key: true,
   auth_methods: ~c"password",
   user_passwords: [{~c"admin", ~c"admin"}]}
]

Then from any other machine:

ssh -p 2222 admin@localhost

That’s it. transport: :ssh on ExRatatui.App routes start_link/1 through a new ExRatatui.SSH.Daemon instead of the local terminal path. The app module itself is unchange. It doesn’t know it’s being served over SSH. auto_host_key: true is the other nice bit. More on that on the docs.

Example: phoenix_ex_ratatui_example.

Integrating with nerves_ssh

If you’re already running nerves_ssh on a Nerves device you don’t need a second daemon. ExRatatui.SSH is an :ssh_server_channel, and nerves_ssh takes one through its subsystems: list. There’s a helper for the tuple shape:

config :nerves_ssh,
  authorized_keys: [File.read!("/root/.ssh/authorized_keys")],
  subsystems: [
    :ssh_sftpd.subsystem_spec(cwd: ~c"/"),
    ExRatatui.SSH.subsystem(MyApp.TUI)
  ]

Connect with:

ssh -t nerves.local -s Elixir.MyApp.TUI

The -t is required — OpenSSH doesn’t allocate a PTY by default for subsystem invocations (sftp and similar binary protocols don’t need one), so without it your local terminal stays in cooked mode and keystrokes get line-buffered and locally echoed over the TUI. The subsystem name is the full Elixir module name as a charlist, so two different app modules configured into the same daemon get distinct names and don’t collide.

Example: nerves_ex_ratatui_example.

What’s next

Reducer runtime for non-trivial apps. More distribution capabilites. More widgets. Contributions and TUIs out there are starting to appear :)!

2 Likes

Maybe this is a shortcoming of the rust library, but support for double-width characters widely used in CJK languages is not complete. For example, if in text input there are some double-width characters then when pressing right arrow when the cursor is at the end of input makes the cursor not visible

1 Like

Hi @ademenev , thanks for the report! You’re right, this is a real gap on our side, not a ratatui shortcoming.

Ratatui handles wide characters correctly; our TextInput widget tracks the cursor and viewport in character counts while the widget width is in terminal cells, so the two drift apart as soon as any CJK character shows up.

I’ve opened an issue to track it here: Text input: cursor becomes invisible at end when line contains double-width (CJK) characters · Issue #45 · mcass19/ex_ratatui · GitHub

I’d be more than happy if you want to take a look and contribute to it. Otherwise I’ll pick it up soon. Either way, really appreciate your comment :).