The callback runtime is the default way to build supervised TUI applications with ExRatatui.App. It follows a LiveView-inspired pattern: mount initial state, render on every state change, and handle events and messages through dedicated callbacks.
This is the mode you want when:
- You're building a straightforward interactive TUI with direct state management.
- You want the simplest possible interface — just
mount,render, andhandle_event. - You don't need first-class command or subscription primitives.
For apps that benefit from an Elm-style architecture with commands, subscriptions, and a unified message path, see the Reducer Runtime guide.
Quick Start
defmodule MyApp.TUI do
use ExRatatui.App
alias ExRatatui.Event
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
@impl true
def mount(_opts) do
{:ok, %{count: 0}}
end
@impl true
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
widget = %Paragraph{
text: "Count: #{state.count}",
style: %Style{fg: :white, modifiers: [:bold]},
alignment: :center,
block: %Block{
title: " Counter ",
borders: [:all],
border_type: :rounded,
border_style: %Style{fg: :cyan}
}
}
[{widget, area}]
end
@impl true
def handle_event(%Event.Key{code: "q", kind: "press"}, state) do
{:stop, state}
end
def handle_event(%Event.Key{code: "up", kind: "press"}, state) do
{:noreply, %{state | count: state.count + 1}}
end
def handle_event(%Event.Key{code: "down", kind: "press"}, state) do
{:noreply, %{state | count: state.count - 1}}
end
def handle_event(_event, state) do
{:noreply, state}
end
endAdd it to your supervision tree:
children = [{MyApp.TUI, []}]
Supervisor.start_link(children, strategy: :one_for_one)Or run it directly:
{:ok, pid} = MyApp.TUI.start_link(name: nil)Callbacks
| Callback | Required | Description |
|---|---|---|
mount/1 | Yes | Called once on startup. Receives opts from start_link/1. Return {:ok, initial_state} or {:error, reason} |
render/2 | Yes | Called after every state change. Receives state and %Frame{} with terminal dimensions. Return [{widget, rect}] |
handle_event/2 | Yes | Called on terminal events (key, mouse, resize). Return {:noreply, state} or {:stop, state} |
handle_info/2 | No | Called for non-terminal messages (e.g., PubSub, Process.send_after). Defaults to {:noreply, state} |
terminate/2 | No | Called on shutdown with reason and final state. Default is a no-op |
mount/1
mount/1 receives the keyword list passed to start_link/1. Use it to set up initial state:
def mount(opts) do
pubsub = Keyword.get(opts, :pubsub)
if pubsub do
Phoenix.PubSub.subscribe(pubsub, "updates")
end
{:ok, %{messages: [], pubsub: pubsub}}
endWhen running over SSH or Erlang distribution, mount/1 also receives :transport, :width, and :height — so your app can adapt its initial state per transport without changing any other callback.
render/2
render/2 receives the current state and a %ExRatatui.Frame{} struct with the terminal's current width and height. Return a list of {widget, rect} tuples — the runtime renders them in order.
See the Building UIs guide for the full widget, layout, and styling reference.
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
[header_area, body_area] =
Layout.split(area, :vertical, [{:length, 3}, {:min, 0}])
[
{%Paragraph{text: "Header"}, header_area},
{%Paragraph{text: "Body: #{inspect(state)}"}, body_area}
]
endhandle_event/2
Terminal events arrive as ExRatatui.Event structs — see the Events section of the Building UIs guide.
def handle_event(%Event.Key{code: "q", kind: "press"}, state) do
{:stop, state}
end
def handle_event(%Event.Key{code: "up", kind: "press"}, state) do
{:noreply, %{state | selected: max(state.selected - 1, 0)}}
end
def handle_event(_event, state) do
{:noreply, state}
endhandle_info/2
Non-terminal messages (PubSub broadcasts, Process.send_after timers, etc.) arrive here:
def handle_info({:new_message, msg}, state) do
{:noreply, %{state | messages: [msg | state.messages]}}
endError Handling and Supervision
ExRatatui apps are supervised GenServers — standard OTP fault tolerance applies:
mount/1raises or returns{:error, reason}: The server stops and the supervisor handles the restart according to its strategy (:one_for_one, etc.). For SSH and distributed transports, the session is cleaned up and the client sees the connection close.render/2raises: The error is logged and the frame is skipped — the server continues running with the previous screen content. This prevents a rendering bug from crashing your app.handle_event/2orhandle_info/2raises: The server crashes and the supervisor restarts it. Since the GenServer has no way to safely continue with potentially corrupted state, a freshmount/1starts from scratch.terminate/2raises: The error is ignored — the process exits regardless. Use this callback for best-effort cleanup (e.g., notifying other processes), not for critical operations.Terminal restoration: On a local transport crash, the terminal is automatically restored (raw mode disabled, cursor shown) via the Rust ResourceArc finalizer when the terminal reference is garbage collected. You don't need to handle this manually.
SSH client disconnect: The SSH channel detects the TCP close, the server receives a shutdown signal, and
terminate/2is called normally.Distributed client disconnect: The server monitors the client process. When the client's node goes down, the monitor fires and the server shuts down cleanly.
For production deployments, set appropriate :max_restarts and :max_seconds on your supervisor to prevent restart loops.
Running Over Transports
The same app module works across all three transports with zero code changes:
children = [
{MyApp.TUI, []}, # local TTY
{MyApp.TUI, transport: :ssh, port: 2222, ...}, # remote over SSH
{MyApp.TUI, transport: :distributed} # remote over distribution
]See the Running TUIs over SSH and Running TUIs over Erlang Distribution guides for transport-specific setup, options, and authentication.
Testing
Start the app under test_mode with explicit dimensions and use ExRatatui.Runtime.inject_event/2 for deterministic input:
test "increments on up key" do
{:ok, pid} = MyApp.TUI.start_link(name: nil, test_mode: {40, 10})
event = %ExRatatui.Event.Key{code: "up", modifiers: [], kind: "press"}
:ok = ExRatatui.Runtime.inject_event(pid, event)
snapshot = ExRatatui.Runtime.snapshot(pid)
assert snapshot.render_count >= 2
GenServer.stop(pid)
endtest_mode disables live terminal input polling so async: true tests don't race ambient TTY events.
Examples
examples/counter_app.exs— simple counter with key eventsexamples/system_monitor.exs— Linux system dashboard with CPU, memory, disk, network, and BEAM stats (also runs over SSH and Erlang distribution)examples/task_manager/— supervised Ecto + SQLite CRUD app with tabs, table, scrollbar, and SSH supportphoenix_ex_ratatui_example— Phoenix app with an admin TUI over SSH and Erlang distribution, sharing PubSub with LiveView (also includes a reducer-runtime TUI)nerves_ex_ratatui_example— Nerves firmware with system monitor and LED control TUIs over SSH subsystems and Erlang distribution (also includes a reducer-runtime system monitor)
Related
ExRatatui.App— behaviour module- Reducer Runtime — alternative runtime with commands and subscriptions
- Building UIs — widgets, layout, styles, and events
- Running TUIs over SSH — SSH transport
- Running TUIs over Erlang Distribution — distribution transport