PhoenixExRatatui.Renderer.Html (PhoenixExRatatui v0.1.0)

Copy Markdown View Source

Encodes ExRatatui.CellSession.Diff payloads for transmission to the browser via Phoenix.LiveView.push_event/3.

LiveView's push_event/3 only accepts JSON-serializable terms — atoms encode as strings (via Jason), but tuples like {:rgb, r, g, b} do not encode cleanly. This module bridges the gap: convert each %CellSession.Cell{} into a fixed-shape array that the JS hook on the other side can read positionally without allocating an object per cell.

Wire shape

A diff payload becomes a map ready for push_event/3:

%{
  "width" => 80,
  "height" => 24,
  "ops" => [
    [row, col, symbol, fg, bg, modifiers, skip],
    ...
  ]
}

Where each value follows these encodings:

  • row, col — integers, zero-indexed
  • symbol — UTF-8 string (single character or grapheme cluster)
  • fg, bg — color encoding:
    • :reset"reset"
    • named atoms (:red, :dark_gray, ...) → matching string ("red")
    • {:rgb, r, g, b}["rgb", r, g, b]
    • {:indexed, n}["indexed", n]
  • modifiers — list of strings in canonical bitflag order (["bold", "italic"]); empty list when no modifiers are set
  • skip — boolean; true means "leave whatever was here"

Why arrays not objects per op

A 200×60 full diff is 12_000 cells. Encoded as objects with named keys ({"row": 0, "col": 0, "symbol": " ", "fg": "reset", ...}) each cell is roughly 80 bytes; encoded as a 7-element array each cell is roughly 30 bytes. At websocket frame scale that's the difference between ~1MB and ~360KB on a single full-paint, before gzip. Since every browser implements Array#0 access in a single CPU instruction, the JS side pays nothing for the positional read.

Defaults are not omitted

Every cell in the diff carries a full 7-element op even when most of its fields are at their default values ("reset", [], false). Omitting defaults would shrink the payload further but would push schema knowledge into the JS hook, and the diff path already filters cells aggressively — frames that "shouldn't" carry a cell at all don't appear in :ops to begin with. We can revisit if profiling flags it.

Summary

Types

JSON-friendly encoded cell: a 7-element list in [row, col, symbol, fg, bg, modifiers, skip] order.

JSON-friendly encoded color: a string for named/reset colors, or a tagged 4- or 2-element array for RGB and indexed colors.

Full diff payload as it appears on the LiveView socket. String map keys (not atoms) so it round-trips cleanly through Jason.

Functions

Encodes a single cell into the 7-element list shape. Exposed for callers that need finer-grained control (e.g. encoding cells from a Snapshot rather than a Diff, or building a diff op by hand).

Encodes a single color value. Named atoms become strings, RGB and indexed colors become tagged arrays.

Encodes an ExRatatui.CellSession.Diff into the JSON-friendly map shape Phoenix.LiveView.push_event/3 ships to the client.

Encodes a modifier list. Each atom becomes its string name; the list preserves canonical bitflag order (set by ExRatatui.CellSession's encoder).

Types

encoded_cell()

@type encoded_cell() :: [
  non_neg_integer() | String.t() | encoded_color() | [String.t()] | boolean(),
  ...
]

JSON-friendly encoded cell: a 7-element list in [row, col, symbol, fg, bg, modifiers, skip] order.

encoded_color()

@type encoded_color() :: String.t() | [String.t() | non_neg_integer(), ...]

JSON-friendly encoded color: a string for named/reset colors, or a tagged 4- or 2-element array for RGB and indexed colors.

encoded_diff()

@type encoded_diff() :: %{
  required(String.t()) => non_neg_integer() | [encoded_cell()]
}

Full diff payload as it appears on the LiveView socket. String map keys (not atoms) so it round-trips cleanly through Jason.

Functions

encode_cell(cell)

@spec encode_cell(ExRatatui.CellSession.Cell.t()) :: encoded_cell()

Encodes a single cell into the 7-element list shape. Exposed for callers that need finer-grained control (e.g. encoding cells from a Snapshot rather than a Diff, or building a diff op by hand).

Examples

iex> alias ExRatatui.CellSession.Cell
iex> cell = %Cell{row: 3, col: 7, symbol: "A", fg: :green, bg: :reset, modifiers: [:bold], skip: false}
iex> PhoenixExRatatui.Renderer.Html.encode_cell(cell)
[3, 7, "A", "green", "reset", ["bold"], false]

encode_color(color)

@spec encode_color(ExRatatui.Style.color()) :: encoded_color()

Encodes a single color value. Named atoms become strings, RGB and indexed colors become tagged arrays.

Examples

iex> PhoenixExRatatui.Renderer.Html.encode_color(:reset)
"reset"

iex> PhoenixExRatatui.Renderer.Html.encode_color(:light_cyan)
"light_cyan"

iex> PhoenixExRatatui.Renderer.Html.encode_color({:rgb, 200, 100, 50})
["rgb", 200, 100, 50]

iex> PhoenixExRatatui.Renderer.Html.encode_color({:indexed, 42})
["indexed", 42]

encode_diff(diff)

@spec encode_diff(ExRatatui.CellSession.Diff.t()) :: encoded_diff()

Encodes an ExRatatui.CellSession.Diff into the JSON-friendly map shape Phoenix.LiveView.push_event/3 ships to the client.

See the moduledoc for the full wire shape.

Examples

iex> alias ExRatatui.CellSession.{Cell, Diff}
iex> diff = %Diff{
...>   width: 2, height: 1,
...>   ops: [%Cell{row: 0, col: 0, symbol: "X", fg: :red, bg: :reset, modifiers: [:bold], skip: false}]
...> }
iex> PhoenixExRatatui.Renderer.Html.encode_diff(diff)
%{
  "width" => 2,
  "height" => 1,
  "ops" => [[0, 0, "X", "red", "reset", ["bold"], false]]
}

encode_modifiers(modifiers)

@spec encode_modifiers([ExRatatui.Style.modifier()]) :: [String.t()]

Encodes a modifier list. Each atom becomes its string name; the list preserves canonical bitflag order (set by ExRatatui.CellSession's encoder).

Examples

iex> PhoenixExRatatui.Renderer.Html.encode_modifiers([])
[]

iex> PhoenixExRatatui.Renderer.Html.encode_modifiers([:bold, :italic])
["bold", "italic"]