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, and handle_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
end

Add 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

CallbackRequiredDescription
mount/1YesCalled once on startup. Receives opts from start_link/1. Return {:ok, initial_state} or {:error, reason}
render/2YesCalled after every state change. Receives state and %Frame{} with terminal dimensions. Return [{widget, rect}]
handle_event/2YesCalled on terminal events (key, mouse, resize). Return {:noreply, state} or {:stop, state}
handle_info/2NoCalled for non-terminal messages (e.g., PubSub, Process.send_after). Defaults to {:noreply, state}
terminate/2NoCalled 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}}
end

When 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}
  ]
end

handle_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}
end

handle_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]}}
end

Error Handling and Supervision

ExRatatui apps are supervised GenServers — standard OTP fault tolerance applies:

  • mount/1 raises 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/2 raises: 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/2 or handle_info/2 raises: The server crashes and the supervisor restarts it. Since the GenServer has no way to safely continue with potentially corrupted state, a fresh mount/1 starts from scratch.

  • terminate/2 raises: 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/2 is 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)
end

test_mode disables live terminal input polling so async: true tests don't race ambient TTY events.

Examples