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 views —
MessageCard,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)
endrender/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
endYou 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
endA 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:
ExRatatui.Widgets.Popup's:contentfieldExRatatui.Widgets.WidgetList's:items
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. RaisesArgumentError. - Returning a non-list —
render/2must return a list, even for a single child or a no-op ([]is valid). - Infinite self-recursion — if your widget's
render/2returns 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!"
endNo need to exercise the protocol directly — the full expand-then-encode pipeline is what you want to cover.