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). - 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 — decoded into an
%ExRatatui.Event.Key{}and forwarded viaTransport.push_event/2. - Runtime emits a frame — encoded via
PhoenixExRatatui.Renderer.Html.encode_diff/1and pushed as aphx_ex_ratatui:renderevent. - Runtime exits (App returned
{:stop, _}, or crash) — we get an EXIT signal, null out:tui, set:tui_endedso the user sees a refresh prompt instead of a frozen-cells display.
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.
Customising LiveView callbacks
All LiveView callbacks (mount/3, render/1, handle_event/3,
handle_info/2) are defoverridable. You can wrap any of them and
call super(...):
def mount(params, session, socket) do
{:ok, socket} = super(params, session, socket)
{:ok, assign(socket, :title, "My TUI")}
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.
Modifier strings are converted with String.to_existing_atom/1 —
the atoms (:ctrl, :alt, :shift, :super, :hyper, :meta)
are pre-loaded by ExRatatui.Event.Key, so untrusted client input
cannot grow 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.