The ExRatatui.Widget protocol lets you build composite widgets in pure Elixir without touching Rust. A custom widget is just a struct you own plus a defimpl that projects it onto primitive widgets — %Paragraph{}, %Block{}, %List{}, and friends — positioned inside the rect you're given. The Bridge expands your widget into primitives before crossing the NIF boundary, so ExRatatui.draw/2 accepts primitive and custom widgets interchangeably at the top level of a frame.

When to reach for a custom widget

If you find yourself repeating the same Layout.split + a handful of primitives in several screens, that's a custom widget. Typical shapes:

  • Composed cards — a title bar, body, and status line that always render together
  • Domain-named viewsMessageCard, FileRow, LogEntry — where the struct IS the model the renderer projects from
  • Simple wrappers — "a Block with these defaults and a Paragraph inside"

Stay with inline primitives for one-off layouts. Custom widgets cost a module and a protocol impl — worth it when you'll reuse the shape or name is part of readability.

The protocol

One callback, stateless, strict return shape:

defprotocol ExRatatui.Widget do
  @spec render(t(), ExRatatui.Layout.Rect.t()) ::
          [{ExRatatui.widget(), ExRatatui.Layout.Rect.t()}]
  def render(widget, rect)
end

render/2 receives your struct and the rect it should occupy, and returns a list of {widget, rect} tuples placing each child. Order matters: earlier entries are drawn first, later entries on top (the usual z-order).

A full example

defmodule MyApp.Widgets.UserCard do
  defstruct [:user, selected?: false]

  defimpl ExRatatui.Widget do
    alias ExRatatui.Layout
    alias ExRatatui.Layout.Rect
    alias ExRatatui.Style
    alias ExRatatui.Widgets.{Block, Paragraph}

    def render(%{user: user, selected?: sel?}, %Rect{} = rect) do
      border_style = if sel?, do: %Style{fg: :yellow}, else: %Style{}

      [header, body] =
        Layout.split(rect, :vertical, [{:length, 1}, {:min, 0}])

      [
        {%Block{title: user.name, borders: [:all], border_style: border_style}, rect},
        {%Paragraph{text: user.handle, style: %Style{modifiers: [:bold]}}, header},
        {%Paragraph{text: user.bio}, body}
      ]
    end
  end
end

You draw it the same way as any primitive:

ExRatatui.draw(terminal, [
  {%MyApp.Widgets.UserCard{user: u, selected?: true},
   %Rect{x: 0, y: 0, width: 40, height: 5}}
])

Composition

A custom widget can return other custom widgets in its children — the expander keeps walking until every entry is a primitive. This is how you build up: a Dashboard that returns two Panels, each of which returns a TitledBox containing Paragraph primitives.

defmodule MyApp.Widgets.Dashboard do
  defstruct [:left_panel, :right_panel]

  defimpl ExRatatui.Widget do
    alias ExRatatui.Layout

    def render(%{left_panel: l, right_panel: r}, rect) do
      [left, right] =
        Layout.split(rect, :horizontal, [{:percentage, 50}, {:percentage, 50}])

      [{l, left}, {r, right}]
    end
  end
end

A safety cap of 32 nesting levels protects against infinite recursion; exceeding it raises ArgumentError with the chain of struct names at fault.

Stateless by design

The protocol has no init/1 / update/2 callbacks. State that evolves over time — keyboard focus, selection, input buffers — lives in your ExRatatui.App or ExRatatui.Session model and is projected onto a fresh struct each frame. Treat the struct as a pure view descriptor, not a mini-actor.

When you need genuinely stateful rendering (like TextInput or Textarea, whose Rust side owns a buffer), use one of the built-in stateful widgets — the protocol is for composition, not state management.

Limitations

Custom widgets are expanded at the top level of the list passed to ExRatatui.draw/2. They are not currently supported inside:

Those nested fields still require primitive widgets. The inverse works fine: a custom widget can itself return a %Popup{} or %WidgetList{} in its children. Only the widgets placed inside Popup/WidgetList stay primitive for now, because their inner rects are computed Rust-side at render time.

Pitfalls

  • Returning the wrong rect type — the second element of each tuple must be a %ExRatatui.Layout.Rect{}, not a plain tuple or map. Raises ArgumentError.
  • Returning a non-listrender/2 must return a list, even for a single child or a no-op ([] is valid).
  • Infinite self-recursion — if your widget's render/2 returns itself (directly or via a cycle) you'll hit the depth cap at 32.
  • Expecting rect clipping validation — children whose rects extend outside the parent are not rejected; ratatui clips at render time.

Testing

Treat custom widgets like any other widget: draw into a test terminal and assert on the rendered buffer.

test "renders greeting" do
  terminal = ExRatatui.init_test_terminal(30, 1)
  rect = %Rect{x: 0, y: 0, width: 30, height: 1}

  :ok = ExRatatui.draw(terminal, [{%Greeting{name: "world"}, rect}])
  assert ExRatatui.get_buffer_content(terminal) =~ "Hello, world!"
end

No need to exercise the protocol directly — the full expand-then-encode pipeline is what you want to cover.