PhoenixExRatatui.LiveView (PhoenixExRatatui v0.1.0)

Copy Markdown View Source

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

How 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}. opts is the keyword list returned by tui_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 as opts to tui_mount/1. Use this to thread per-connection context (current user, params) from Phoenix.LiveView assigns 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}}
end

Lifecycle

  • HTTP mount (not connected?) — empty assigns, render hook container, no Transport. Per phoenix-thinking's no-work-in-mount rule: mount/3 runs twice (HTTP request + WebSocket handshake).
  • WebSocket mountProcess.flag(:trap_exit, true) so a mount-failing TUI returns {:error, _} cleanly from Transport.start_link/1 instead of killing the LV (which would trigger an infinite client reconnect loop).
  • First phx_ex_ratatui:resize — call tui_mount_opts/1, start the Transport at {cols, rows} driving the generated Runtime proxy.
  • Subsequent resizesTransport.resize/3 updates the CellSession and notifies the runtime.
  • Hook input — decoded into an %ExRatatui.Event.Key{} and forwarded via Transport.push_event/2.
  • Runtime emits a frame — encoded via PhoenixExRatatui.Renderer.Html.encode_diff/1 and pushed as a phx_ex_ratatui:render event.
  • Runtime exits (App returned {:stop, _}, or crash) — we get an EXIT signal, null out :tui, set :tui_ended so 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's getElementById queries don't collide.
  • :runtime:callbacks (default) or :reducer. Selects the ExRatatui.App runtime style used by the generated proxy. Reducer-runtime modules implement tui_init/1, tui_render/2, tui_update/2 (with {:event, _} / {:info, _} wrapped messages), and optionally tui_subscriptions/1, instead of the callbacks-runtime tui_mount/1 / tui_handle_event/2 / tui_handle_info/2 quartet. 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")}
end

For 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

__using__(opts)

(macro)

Generates the unified LiveView + App module. See the moduledoc.

decode_input(params)

@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"}

dispatch_intent(socket, intent)

@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.