This guide walks you from an empty mix new project to a supervised terminal UI you can actually use. Read it top-to-bottom the first time; come back later for the capstone module.
Every concept is introduced with runnable code. If you get stuck, compare your file against the snippets.
What you'll build
A small todo app. Type an item, press Enter to add it, move selection with ↑/↓ (or k/j), press d to delete, Tab to switch focus between the input and the list, Esc to quit.
┌────────────────────────────────────────────────────────┐
│ Todo │
├────────────────────────────────────────────────────────┤
│ › buy groceries_ │
├────────────────────────────────────────────────────────┤
│ walk the dog │
│ ›file taxes │
│ call mom │
│ │
└────────────────────────────────────────────────────────┘
Tab switch · Enter add · d delete · Esc quitInstall and verify
Create a project and add the dependency:
mix new my_tui --sup
cd my_tui
Edit mix.exs:
defp deps do
[
{:ex_ratatui, "~> 0.8"}
]
endFetch:
mix deps.get
mix compile
A precompiled NIF for your platform downloads automatically — you don't need Rust installed. On first compile you'll see rustler_precompiled fetch the binary.
Verify it works. Create lib/hello.ex:
defmodule Hello do
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
def run do
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_key()
end)
end
defp wait_for_key do
case ExRatatui.poll_event(5_000) do
nil -> wait_for_key()
_ -> :ok
end
end
endRun it:
iex -S mix
iex> Hello.run()
You should see a rounded box with a green centered message. Press any key to return to IEx.
If instead you get terminal_init_failed, you're likely in a non-TTY shell (IDE terminal, background process, piped stdin). Run from a real terminal emulator — see the Debugging guide for more.
Your first render
Let's look at what that snippet actually did.
ExRatatui.run(fn terminal ->
# ...
end)ExRatatui.run/1 takes a function, puts the terminal into raw mode, gives you a terminal reference, and guarantees the terminal is restored when the function returns (or raises). This is the right shape for a one-shot script but not for a supervised app — we'll replace it in a minute.
{w, h} = ExRatatui.terminal_size()Returns the current size in cells. The terminal won't automatically resize your widgets — you place them explicitly, in cell coordinates.
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}
}A widget is a struct. It doesn't draw anything on its own — it's a value you hand to draw/2. Paragraph renders text; Block is a decorative wrapper most widgets accept via a :block field for a title and borders.
ExRatatui.draw(terminal, [{paragraph, %Rect{x: 0, y: 0, width: w, height: h}}])draw/2 takes a list of {widget, rect} tuples. Each %Rect{} says where to paint the widget. You can pass many tuples — the whole frame is rendered in one go.
ExRatatui.poll_event(5_000)Polls for a terminal event (key press, mouse, resize) with a timeout in milliseconds. Returns nil on timeout, an %Event.Key{} / %Event.Mouse{} / %Event.Resize{} otherwise. Event polling runs on the BEAM's DirtyIo scheduler so your other processes keep running.
That's the whole "bare metal" API: size, build structs, draw, poll. For a supervised long-running app, you want the next layer.
Switch to ExRatatui.App
ExRatatui.run/1 is fine for scripts. For a real app you want supervision, test support, and transports (SSH, distribution). That's what the ExRatatui.App behaviour gives you.
Replace lib/hello.ex with:
defmodule Hello do
use ExRatatui.App
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
@impl true
def mount(_opts) do
{:ok, %{}}
end
@impl true
def render(_state, frame) do
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}
}
}
[{paragraph, %Rect{x: 0, y: 0, width: frame.width, height: frame.height}}]
end
@impl true
def handle_event(%ExRatatui.Event.Key{kind: "press"}, state) do
{:stop, state}
end
def handle_event(_event, state), do: {:noreply, state}
endThree callbacks:
mount/1— runs once when the app starts. Returns{:ok, state}. Here the state is empty.render/2— runs after every state change. Getsstateand a%Frame{width:, height:}with the current terminal size. Returns[{widget, rect}, ...]. Always return the full scene — the runtime diffs cells between frames for you, but your job is to describe the whole screen.handle_event/2— receives a terminal event. Returns{:noreply, new_state}to keep running or{:stop, state}to quit. Here any key press quits.
Run it:
iex -S mix
iex> {:ok, _pid} = Hello.start_link(name: nil)
iex> Process.monitor(_pid) # optional — block until the app exits
Under a supervisor, you'd add it like any other child:
# lib/my_tui/application.ex
children = [
Hello
]Same behavior as before, but now you have a proper OTP process you can test, supervise, and serve remotely. The SSH and Distribution guides serve this exact module over the network with no code changes.
State and events
A static paragraph isn't very interactive. Let's add a counter.
defmodule Hello do
use ExRatatui.App
alias ExRatatui.{Event, Layout}
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
@impl true
def mount(_opts), do: {:ok, %{count: 0}}
@impl true
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
widget = %Paragraph{
text: "\n\n Count: #{state.count}",
style: %Style{fg: :white, modifiers: [:bold]},
alignment: :center,
block: %Block{title: " Counter ", borders: [:all], border_type: :rounded}
}
[{widget, area}]
end
@impl true
def handle_event(%Event.Key{code: "q", kind: "press"}, state), do: {:stop, state}
def handle_event(%Event.Key{code: code, kind: "press"}, state) when code in ["up", "k"] do
{:noreply, %{state | count: state.count + 1}}
end
def handle_event(%Event.Key{code: code, kind: "press"}, state) when code in ["down", "j"] do
{:noreply, %{state | count: state.count - 1}}
end
def handle_event(_, state), do: {:noreply, state}
endTwo things to notice.
Events come in handle_event clauses. An %Event.Key{} carries :code (a string like "up", "a", "enter", "esc"), :modifiers (e.g. [:ctrl]), and :kind ("press" / "repeat" / "release"). Pattern-match on the shape you care about; fall through to a catch-all {:noreply, state} so unhandled events don't crash.
Return values control the loop:
{:noreply, state}— update state and re-render{:noreply, state, opts}— same, with options likerender?: falseto skip the re-render (see the Performance guide){:stop, state}— exit cleanly
Run it, press ↑/↓ or k/j, watch the counter. Press q to quit.
Layout
Placing everything in one giant Rect gets old fast. Layout.split/3 divides a rectangle into regions using constraints.
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
[header, body, footer] = Layout.split(area, :vertical, [
{:length, 3},
{:min, 0},
{:length, 1}
])
header_widget = %Paragraph{
text: " Counter",
style: %Style{fg: :cyan, modifiers: [:bold]},
block: %Block{borders: [:all], border_type: :rounded}
}
body_widget = %Paragraph{
text: "\n\n Count: #{state.count}",
alignment: :center,
block: %Block{borders: [:all], border_type: :rounded}
}
footer_widget = %Paragraph{
text: " ↑/k +1 · ↓/j -1 · q quit",
style: %Style{fg: :dark_gray}
}
[{header_widget, header}, {body_widget, body}, {footer_widget, footer}]
endConstraint types you'll use most:
{:length, n}— exactlyncells{:min, n}— at leastn, expand to fill remaining space{:percentage, n}—n% of the parent{:ratio, num, den}—num/denof the parent
Layout.split/3 returns a list of %Rect{} in the same order as your constraints. Chain splits to build grids — split vertically into rows, then split each row horizontally into columns. The Building UIs guide goes deep on constraints.
Styling and rich text
%Style{} carries foreground, background, and modifiers. Accepts named colors (:green), RGB ({:rgb, 255, 100, 0}), and 256-color indices ({:indexed, 42}).
%Style{fg: :red, bg: {:rgb, 30, 30, 30}, modifiers: [:bold, :underlined]}Most widgets take a top-level :style plus part-specific fields like :highlight_style or :border_style.
Rich text lets one string carry per-segment styling. Build a %Span{} for a styled run and group them with %Line{}:
alias ExRatatui.Text.{Line, Span}
%Paragraph{
text: Line.new([
Span.new(" ok ", style: %Style{fg: :black, bg: :green}),
Span.new(" Count: #{state.count}", style: %Style{modifiers: [:bold]})
])
}Paragraph.text, List.items, Table cells, Tabs.titles, and Block.title all accept rich text. Plain strings keep working everywhere — you only reach for spans when you want mixed styling on one line.
Make negative counts red:
style = if state.count < 0, do: %Style{fg: :red}, else: %Style{fg: :white}
body_widget = %Paragraph{
text: "\n\n Count: #{state.count}",
style: style,
alignment: :center,
block: %Block{borders: [:all], border_type: :rounded}
}Capstone: a small todo app
Time to put it together. This app introduces two things you haven't seen:
- A stateful widget —
TextInputowns a NIF-side editor state. You create the state once inmount/1, keep the reference in your state map, and pass it to the widget on every render. Focus management — two regions (input, list) want different keybindings. We track a
focus: :input | :listatom and dispatch keys accordingly. For multi-panel apps with more than a couple of regions,ExRatatui.Focusgives you a proper focus ring; see Building UIs for that pattern.
Create lib/todo.ex:
defmodule Todo do
use ExRatatui.App
alias ExRatatui.{Event, Layout, Style}
alias ExRatatui.Layout.Rect
alias ExRatatui.Widgets.{Block, List, Paragraph, TextInput}
@impl true
def mount(_opts) do
{:ok,
%{
input: ExRatatui.text_input_new(),
items: [],
selected: 0,
focus: :input
}}
end
@impl true
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
[header, input_rect, list_rect, footer] =
Layout.split(area, :vertical, [
{:length, 3},
{:length, 3},
{:min, 0},
{:length, 1}
])
[
{header_widget(), header},
{input_widget(state), input_rect},
{list_widget(state), list_rect},
{footer_widget(), footer}
]
end
# ---- events ----------------------------------------------------------
@impl true
def handle_event(%Event.Key{code: "esc", kind: "press"}, state) do
{:stop, state}
end
def handle_event(%Event.Key{code: "tab", kind: "press"}, state) do
{:noreply, toggle_focus(state)}
end
def handle_event(%Event.Key{kind: "press"} = key, %{focus: :input} = state) do
{:noreply, handle_input_key(state, key)}
end
def handle_event(%Event.Key{kind: "press"} = key, %{focus: :list} = state) do
{:noreply, handle_list_key(state, key)}
end
def handle_event(_event, state), do: {:noreply, state}
# ---- input focus -----------------------------------------------------
defp handle_input_key(state, %Event.Key{code: "enter"}) do
case ExRatatui.text_input_get_value(state.input) do
"" ->
state
text ->
:ok = ExRatatui.text_input_set_value(state.input, "")
%{state | items: state.items ++ [text]}
end
end
defp handle_input_key(state, %Event.Key{code: code}) do
:ok = ExRatatui.text_input_handle_key(state.input, code)
state
end
# ---- list focus ------------------------------------------------------
defp handle_list_key(state, %Event.Key{code: code}) when code in ["up", "k"] do
%{state | selected: max(state.selected - 1, 0)}
end
defp handle_list_key(state, %Event.Key{code: code}) when code in ["down", "j"] do
max_index = max(length(state.items) - 1, 0)
%{state | selected: min(state.selected + 1, max_index)}
end
defp handle_list_key(state, %Event.Key{code: "d"}) do
items = delete_at(state.items, state.selected)
%{state | items: items, selected: min(state.selected, max(length(items) - 1, 0))}
end
defp handle_list_key(state, _), do: state
# ---- widgets ---------------------------------------------------------
defp header_widget do
%Paragraph{
text: " Todo",
style: %Style{fg: :cyan, modifiers: [:bold]},
block: %Block{borders: [:all], border_type: :rounded, border_style: %Style{fg: :dark_gray}}
}
end
defp input_widget(state) do
%TextInput{
state: state.input,
placeholder: "Add a todo…",
block: %Block{
borders: [:all],
border_type: :rounded,
border_style: border_style(state.focus, :input)
}
}
end
defp list_widget(state) do
%List{
items: state.items,
selected: if(state.items == [], do: nil, else: state.selected),
highlight_symbol: "› ",
highlight_style: %Style{fg: :black, bg: :yellow, modifiers: [:bold]},
block: %Block{
borders: [:all],
border_type: :rounded,
border_style: border_style(state.focus, :list)
}
}
end
defp footer_widget do
%Paragraph{
text: " Tab switch · Enter add · d delete · Esc quit",
style: %Style{fg: :dark_gray}
}
end
# ---- helpers ---------------------------------------------------------
defp toggle_focus(%{focus: :input} = state), do: %{state | focus: :list}
defp toggle_focus(%{focus: :list} = state), do: %{state | focus: :input}
defp border_style(focus, id) do
if focus == id,
do: %Style{fg: :yellow, modifiers: [:bold]},
else: %Style{fg: :dark_gray}
end
defp delete_at(list, n), do: Enum.take(list, n) ++ Enum.drop(list, n + 1)
endRun it:
iex -S mix
iex> {:ok, pid} = Todo.start_link(name: nil)
iex> ref = Process.monitor(pid)
iex> receive do {:DOWN, ^ref, _, _, _} -> :ok end
Things worth highlighting:
TextInputstate is created inmount/1, never inrender/2. Creating it on every render would lose the cursor and typed text between frames. The reference lives in state; the widget just reads it.handle_event/2dispatches by focus. The patterndef handle_event(%Event.Key{…} = key, %{focus: :input} = state)is a clean way to split behavior without nestedcase.- Guard against empty state.
List.selectedis set tonilwhen there are no items so you don't highlight row zero of an empty list. Delete clamps the new selection to the new length. - Focus shows visually. The focused region gets a bold yellow border. This is a minimal pattern — for more panels, reach for
ExRatatui.Focus.
Where to go next
You now have a working local supervised TUI with input, a list, and focus. From here, pick the guide that matches what you want to learn:
- Building UIs — full widget reference, layout deep-dive, rich text, events,
ExRatatui.Focus. - Callback Runtime — all callbacks (
mount,render,handle_event,handle_info,terminate), options, lifecycle details. - Reducer Runtime — Elm-style alternative with first-class commands, subscriptions, and runtime inspection. Good when you have async work or want structured side effects.
- Custom Widgets — compose primitives into reusable widgets via the
ExRatatui.Widgetprotocol. - State Machine Patterns — multi-screen apps, modals, conditional UI.
- Testing — headless test backend,
inject_event,Runtime.snapshot, property-based tests. - Debugging —
Runtime.snapshot, tracing, buffer inspection, common errors. - Performance —
render?: false, poll tuning, keepingrender/2cheap. - Running TUIs over SSH — serve this exact app to remote clients.
- Running TUIs over Erlang Distribution — drive the TUI from a different BEAM node.
Or browse the examples/ folder for more patterns — focus_multi_panel.exs, chat_interface.exs, and task_manager/ are good next reads.