Macro that turns the calling module into a Phoenix.LiveComponent
hosting an embedded TUI — the same module is both the component and
the ExRatatui.App it drives.
Where PhoenixExRatatui.LiveView makes the page itself a TUI, this
macro drops a TUI inside an existing LiveView alongside whatever else
that LV is rendering — admin dashboards, dev consoles, half-page
overlays.
Quick start
defmodule MyAppWeb.AdminCounterPanel do
use PhoenixExRatatui.LiveComponent
def tui_mount(_opts), do: {:ok, %{count: 0}}
def tui_render(state, frame), do: [...]
def tui_handle_event(_event, state), do: {:noreply, state}
end
# In a parent LiveView:
defmodule MyAppWeb.AdminLive do
use Phoenix.LiveView
def render(assigns) do
~H"""
<h1>Admin Dashboard</h1>
<.live_component module={MyAppWeb.AdminCounterPanel} id="admin-counter" />
<p>Other admin content</p>
"""
end
endHow it works
Same trick as PhoenixExRatatui.LiveView: the macro injects
Phoenix.LiveComponent callbacks AND, via @after_compile,
generates a sibling Module.Runtime proxy that conforms to
ExRatatui.App by delegating to the tui_* callbacks. The
handle_info/2 arity collision between LV and ExRatatui.App is
the same in LiveComponents (well, LCs don't have handle_info, but
the abstraction stays consistent).
TUI callbacks
Same as PhoenixExRatatui.LiveView:
tui_mount/1,tui_render/2,tui_handle_event/2,tui_handle_info/2,tui_terminate/2,tui_mount_opts/1
See PhoenixExRatatui.LiveView's moduledoc for full callback
semantics.
Threading parent assigns into the App
The component's update/2 receives assigns from the parent LV.
Override tui_mount_opts/1 (which gets the component socket) to
thread them into tui_mount/1:
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
def tui_mount_opts(socket), do: [user: socket.assigns.user]
def tui_mount(opts), do: {:ok, %{user: opts[:user]}}Wire model
LiveComponents share the parent LV's process — they don't have
their own mailbox or handle_info/2. We hand the runtime Server a
writer that calls Phoenix.LiveView.send_update/3 instead of
send/2. Each rendered diff arrives in update(%{tui_diff: diff}, socket) and flows out to the client via push_event/3.
Mount-failure handling matches the LiveView macro: if tui_mount/1
returns {:error, _}, the runtime server's init returns
{:stop, reason}, the linked parent LV gets the EXIT, and we set
:tui_error so the next render shows a fallback. The parent LV
must trap exits for this to work — otherwise the EXIT signal
kills the whole LV. We set Process.flag(:trap_exit, true) lazily
on the first resize event before calling Transport.start_link/1.
This sets it on the parent LV process; if that LV traps exits for
other reasons, the flag is shared.
Intents and the parent LV
When the embedded TUI emits intents (e.g. returning {:noreply, state, intents: [{:navigate, "/login"}]} from tui_handle_event/2), the
intent must dispatch from the parent LV's process, not the
LiveComponent — Phoenix LV forbids redirects from inside
LiveComponent.update/2.
This module's intent_writer therefore sends a regular message to
the parent LV: send(parent_pid, {:phoenix_ex_ratatui, :intent, intent}). The parent LV must have a handle_info/2 clause that
picks it up:
def handle_info({:phoenix_ex_ratatui, :intent, intent}, socket) do
{:noreply, PhoenixExRatatui.LiveView.dispatch_intent(socket, intent)}
endPhoenixExRatatui.LiveView.dispatch_intent/2 handles the standard
intent shapes ({:navigate, _}, {:patch, _}, {:redirect, _}).
When the LC is embedded inside another PhoenixExRatatui.LiveView
(rare but supported), this clause is generated automatically. For the
common case — embedding inside a plain Phoenix.LiveView — copy the
snippet above.
Telemetry
Same events as PhoenixExRatatui.LiveView — see
PhoenixExRatatui.Telemetry.
Summary
Functions
Generates the unified LiveComponent + App module. See the moduledoc.