Macro that turns the calling module into a full-page TUI route — the
same module is both a Phoenix.LiveView and the ExRatatui.App that
drives it.
Quick start
# In the router (no special macro needed):
live "/tui", MyAppWeb.MyTuiLive
# The live module:
defmodule MyAppWeb.MyTuiLive do
use PhoenixExRatatui.LiveView
alias ExRatatui.Layout.Rect
alias ExRatatui.Widgets.Paragraph
def tui_mount(_opts), do: {:ok, %{count: 0}}
def tui_render(state, frame) do
[{%Paragraph{text: "Count: #{state.count}"},
%Rect{x: 0, y: 0, width: frame.width, height: frame.height}}]
end
def tui_handle_event(%ExRatatui.Event.Key{code: "+"}, state),
do: {:noreply, %{state | count: state.count + 1}}
def tui_handle_event(%ExRatatui.Event.Key{code: "q"}, state),
do: {:stop, state}
def tui_handle_event(_event, state), do: {:noreply, state}
endHow it works
use PhoenixExRatatui.LiveView injects a full Phoenix.LiveView
implementation (mount/3, render/1, handleevent/3, handle_info/2)
AND, via @after_compile, generates a sibling
MyAppWeb.MyTuiLive.Runtime module that implements ExRatatui.App
by delegating to the `tui*` callbacks on the calling module.
The runtime proxy exists because Phoenix.LiveView.handle_info/2
(msg, socket) and ExRatatui.App.handle_info/2 (msg, state) collide
on arity. Splitting the App into a hidden submodule lets both
behaviours live side-by-side without renaming Phoenix LV callbacks.
The proxy needs no attention — define the tui_* callbacks and the
macro handles the rest.
TUI callbacks (override as needed)
tui_mount(opts)— return{:ok, state},{:ok, state, runtime_opts}, or{:error, reason}.optsis the keyword list returned bytui_mount_opts/1.tui_render(state, frame)— return a list of{widget, rect}tuples. Default:[].tui_handle_event(event, state)— return{:noreply, state}or{:stop, state}. Default:{:noreply, state}.tui_handle_info(msg, state)— same shape; for messages sent to the runtime server (PubSub,send/2). Default:{:noreply, state}.tui_terminate(reason, state)— cleanup. Default::ok.tui_mount_opts(socket)— return the keyword list passed asoptstotui_mount/1. Use this to thread per-connection context (current user, params) fromPhoenix.LiveViewassigns into the App. Default:[].
Threading socket data into the App
tui_mount_opts/1 is the bridge:
defmodule MyAppWeb.AdminTui do
use PhoenixExRatatui.LiveView
@impl Phoenix.LiveView
def mount(_params, session, socket) do
{:ok, socket} = super(nil, nil, socket)
{:ok, Phoenix.Component.assign(socket, :user_id, session["user_id"])}
end
def tui_mount_opts(socket), do: [user_id: socket.assigns.user_id]
def tui_mount(opts), do: {:ok, %{user_id: opts[:user_id], n: 0}}
endLifecycle
- HTTP mount (not
connected?) — empty assigns, render hook container, no Transport. Perphoenix-thinking's no-work-in-mount rule:mount/3runs twice (HTTP request + WebSocket handshake). - WebSocket mount —
Process.flag(:trap_exit, true)so a mount-failing TUI returns{:error, _}cleanly fromTransport.start_link/1instead of killing the LV (which would trigger an infinite client reconnect loop). Also attaches the library's:handle_event/:handle_infolifecycle hooks (see__attach_hooks__/3). - First
phx_ex_ratatui:resize— calltui_mount_opts/1, start the Transport at{cols, rows}driving the generatedRuntimeproxy. - Subsequent resizes —
Transport.resize/3updates theCellSessionand notifies the runtime. - Hook input — the
:handle_eventhook decodes it into an%ExRatatui.Event.Key{}and forwards it viaTransport.push_event/2. - Runtime emits a frame — the
:handle_infohook encodes it viaPhoenixExRatatui.Renderer.Html.encode_diff/1and pushes aphx_ex_ratatui:renderevent. - Runtime exits (App returned
{:stop, _}, or crash) — the:handle_infohook gets the server's EXIT signal, nulls out:tui, sets:tui_endedso the user sees a refresh prompt instead of a frozen-cells display. EXITs from any other process pass through to the user's ownhandle_info/2.
Options
:container_id— DOM id for the hook container. Defaults to"phoenix-ex-ratatui". Override when embedding multiple TUI pages on the same router so the JS hook'sgetElementByIdqueries don't collide.:runtime—:callbacks(default) or:reducer. Selects theExRatatui.Appruntime style used by the generated proxy. Reducer-runtime modules implementtui_init/1,tui_render/2,tui_update/2(with{:event, _}/{:info, _}wrapped messages), and optionallytui_subscriptions/1, instead of the callbacks-runtimetui_mount/1/tui_handle_event/2/tui_handle_info/2quartet. See ExRatatui.App for the runtime distinction.
Defining your own LiveView callbacks
mount/3 and render/1 are defoverridable — wrap either and call
super(...):
def mount(params, session, socket) do
{:ok, socket} = super(params, session, socket)
{:ok, assign(socket, :title, "My TUI")}
endhandle_event/3 and handle_info/2 are not injected, so define
them normally. The library consumes its own browser events
(phx_ex_ratatui:*) and internal messages ({:phoenix_ex_ratatui, …},
the runtime server's {:EXIT, …}) through Phoenix.LiveView lifecycle
hooks attached in mount/3. Those hooks run first and pass everything
else through, so your own phx-click handlers, PubSub, and timers
coexist with the TUI — no super, no special callback names:
def handle_info({:new_message, msg}, socket) do
# the TUI's render messages are consumed by the hook before this
# runs; here you only see your own messages
{:noreply, assign(socket, :latest, msg)}
endFor mixed pages where the TUI is one of several pieces of UI, reach for
PhoenixExRatatui.LiveComponent instead.
Summary
Functions
Generates the unified LiveView + App module. See the moduledoc.
Decodes the phx_ex_ratatui:input payload the JS hook sends into an
ExRatatui.Event.t/0.
Dispatches a runtime intent against a LiveView socket.
Functions
Generates the unified LiveView + App module. See the moduledoc.
@spec decode_input(map()) :: ExRatatui.Event.t()
Decodes the phx_ex_ratatui:input payload the JS hook sends into an
ExRatatui.Event.t/0.
Modifiers stay as the string list ExRatatui.Event.Key uses
("ctrl", "shift", "alt", "meta"), so an App matching on
%ExRatatui.Event.Key{modifiers: ["ctrl"]} behaves the same here as
it does over SSH or in a terminal. Keeping them as strings (rather
than atoms) also means untrusted client input never grows the atom
table.
Examples
iex> event = PhoenixExRatatui.LiveView.decode_input(%{"kind" => "key", "code" => "a", "modifiers" => ["ctrl"]})
iex> {event.code, event.modifiers, event.kind}
{"a", ["ctrl"], "press"}
@spec dispatch_intent(Phoenix.LiveView.Socket.t(), term()) :: Phoenix.LiveView.Socket.t()
Dispatches a runtime intent against a LiveView socket.
Intents are emitted by an ExRatatui.App from tui_handle_event/2
or tui_handle_info/2 via the third element of a {:noreply, state, intents: [...]} (or {:stop, ...}) transition. They flow through
ExRatatui.Server's intent_writer_fn to this LV, where this
helper maps the intent shape to the equivalent Phoenix LV action.
Recognised intents:
{:navigate, path}—Phoenix.LiveView.push_navigate(socket, to: path){:patch, path}—Phoenix.LiveView.push_patch(socket, to: path){:redirect, path}—Phoenix.LiveView.redirect(socket, to: path){:redirect, [external: url]}— external redirect
Unrecognised intents are dropped and logged at warning level. This keeps a TUI app forward-compatible: a future intent the consumer doesn't know how to handle yet won't crash the LV.
Public so PhoenixExRatatui.LiveComponent can reuse the same
dispatch table.