PhoenixExRatatui.Transport (PhoenixExRatatui v0.1.0)

Copy Markdown View Source

Connection-level helper that wires an ExRatatui.App to a Phoenix.LiveView (or Phoenix.LiveComponent) over a freshly constructed ExRatatui.CellSession.

Implements the ExRatatui.Transport behaviour as a marker — Phoenix doesn't supervise transports the way ExRatatui.SSH.Daemon does, so there's no child_spec/1. Each LiveView mount calls start_link/1 with target: self(), holds onto the returned %{server, cell_session} references in its assigns, and pattern-matches on the rendered %CellSession.Diff{} payloads in handle_info/2.

Wire protocol

Outbound (server → LiveView):

{:phoenix_ex_ratatui, :render, %ExRatatui.CellSession.Diff{}}

Inbound (LiveView → server) — same as every byte-stream transport:

{:ex_ratatui_event, %ExRatatui.Event.t{}}
{:ex_ratatui_resize, w, h}

Use push_event/2 and resize/3 rather than sending those messages by hand — both also handle the CellSession.resize/3 step that must precede a resize message.

Example mount

def mount(_params, _session, socket) do
  if connected?(socket) do
    {:ok, refs} =
      PhoenixExRatatui.Transport.start_link(
        mod: MyApp,
        width: 80,
        height: 24,
        target: self()
      )

    {:ok, assign(socket, tui: refs)}
  else
    {:ok, assign(socket, tui: nil)}
  end
end

def handle_info({:phoenix_ex_ratatui, :render, diff}, socket) do
  {:noreply, push_event(socket, "render", encode_for_client(diff))}
end

The server is started linked to the caller. When the LiveView process exits (browser disconnect, navigation, crash), the server's terminate/2 runs deterministically: closes the CellSession, calls the user terminate/2, emits transport-disconnect telemetry. We do not rely on Phoenix.LiveView.terminate/2 (which only fires when the socket is trap_exit-aware, and we don't recommend that).

Summary

Types

References returned from start_link/1. Hold onto all three — the :server pid for push_event/2 and stop/2, the :cell_session for resize/3 (which must resize the session before notifying the server), and :mod for telemetry / debug contexts where the App module that's being driven matters.

Functions

Sends a decoded terminal event to the runtime server. Wrapper around the {:ex_ratatui_event, _} mailbox protocol so callers don't have to know the message tag.

Resizes the underlying CellSession and notifies the server so the next render uses the new dimensions.

Constructs an ExRatatui.CellSession at width x height and starts an ExRatatui.Server driving mod against it. The server ships rendered cell diffs to target as {:phoenix_ex_ratatui, :render, %CellSession.Diff{}} messages.

Stops the runtime server cleanly. The CellSession is closed by the server's terminate/2; no separate cleanup is needed.

Types

refs()

@type refs() :: %{
  server: pid(),
  cell_session: ExRatatui.CellSession.t(),
  mod: module()
}

References returned from start_link/1. Hold onto all three — the :server pid for push_event/2 and stop/2, the :cell_session for resize/3 (which must resize the session before notifying the server), and :mod for telemetry / debug contexts where the App module that's being driven matters.

Functions

push_event(server, event)

@spec push_event(pid() | refs(), ExRatatui.Event.t()) :: :ok

Sends a decoded terminal event to the runtime server. Wrapper around the {:ex_ratatui_event, _} mailbox protocol so callers don't have to know the message tag.

Used by the LiveView's client-side hook to forward keypresses, mouse clicks, and synthetic events from the browser into the App's handle_event/2.

resize(map, width, height)

@spec resize(refs(), pos_integer(), pos_integer()) :: :ok | {:error, term()}

Resizes the underlying CellSession and notifies the server so the next render uses the new dimensions.

The two operations happen in this order on purpose: the server's resize handler updates the cached size before the next render, but it does not touch the session itself — that's the transport's job. Calling resize/3 here gets it right; sending {:ex_ratatui_resize, _, _} directly to the server would leave the session stuck at the old size and the next take_cells_diff/1 would emit a stale full payload.

Returns {:error, reason} if CellSession.resize/3 fails (e.g. the session was already closed); the server is not notified in that case.

start_link(opts)

@spec start_link(keyword()) :: {:ok, refs()} | {:error, term()}

Constructs an ExRatatui.CellSession at width x height and starts an ExRatatui.Server driving mod against it. The server ships rendered cell diffs to target as {:phoenix_ex_ratatui, :render, %CellSession.Diff{}} messages.

Options

  • :mod (required) — module implementing ExRatatui.App.
  • :width (required) — initial terminal width in cells. Must be >= 1.
  • :height (required) — initial terminal height in cells. Must be >= 1.
  • :target (required) — pid/0 the runtime server should be linked to (typically self() from a LiveView mount). Used by the default writer if :writer is not given.
  • :writer (optional) — a 1-arity function called with each rendered %CellSession.Diff{}. Defaults to a function that sends {:phoenix_ex_ratatui, :render, diff} to :target. PhoenixExRatatui.LiveComponent overrides this to call Phoenix.LiveView.send_update/3 instead, since LiveComponents have no handle_info/2 and must receive updates via update/2.
  • :intent_writer (optional) — a 1-arity function called with each runtime intent (e.g. {:navigate, "/path"}). Defaults to a function that sends {:phoenix_ex_ratatui, :intent, intent} to :target. PhoenixExRatatui.LiveComponent overrides this to route intents through Phoenix.LiveView.send_update/3 for the same reason as :writer.
  • Any other option — passed through verbatim to mod.mount/1. Use this to thread per-connection context (current user, params, LiveView socket id) into the App without a global registry.

Return shape

{:ok, %{server: server_pid, cell_session: %CellSession{}}}

Returns whatever error tuple mod.mount/1 produced if mount fails; the CellSession is closed defensively before propagating.

The server is linked to the calling process. When the LiveView exits, the server's terminate/2 runs and closes the session.

stop(refs, reason \\ :normal)

@spec stop(refs(), term()) :: :ok

Stops the runtime server cleanly. The CellSession is closed by the server's terminate/2; no separate cleanup is needed.

Idempotent in the practical sense: calling stop/2 after the server has already exited is harmless (the call returns immediately). For graceful shutdown from a LiveView's terminate/3, prefer relying on the link — letting the LiveView exit naturally tears down the server via the standard EXIT path.