ExRatatui is Elixir bindings for the Rust ratatui terminal UI library, via Rustler NIFs. It builds rich terminal UIs that run on the BEAM's DirtyIo scheduler, so rendering never blocks application processes.
It is not the Rust ratatui API and not bubbletea. Widgets are plain Elixir structs (pure view descriptors), assembled each frame and handed to a draw/render function. There are no stateful widget objects that update in place.
Choosing a runtime
Pick the runtime before writing any code — this is the single most important decision and the easiest to get wrong.
| Use | When | Shape |
|---|---|---|
ExRatatui.run/2 | Scripts, one-shots, examples, throwaway demos | A closure that draws and polls events itself |
use ExRatatui.App | Supervised interactive apps (the default) | LiveView-style mount/1, render/2, handle_event/2, handle_info/2 |
use ExRatatui.App, runtime: :reducer | Apps wanting pure transitions, declarative timers, and managed side effects | init/1, update/2, render/2, subscriptions/1 + Command/Subscription |
- The callback runtime is the default and fits most apps. Reach for the reducer runtime when modeling the app as
(msg, state) -> statewithCommand-driven effects andSubscription-driven timers is worth the structure. - Both
Appruntimes are supervised: add{MyApp.TUI, opts}to a supervision tree. Never hand-roll the loop withrun/2for a long-lived interactive app.
Core API shape
Canonical entry points for a raw run/2 loop — match these signatures exactly, do not invent Rust- or bubbletea-shaped calls:
# fun receives a terminal reference; terminal is restored on exit, even on raise
ExRatatui.run(fn terminal ->
{width, height} = ExRatatui.terminal_size()
# draw/2 takes a LIST of {widget_struct, %Rect{}} tuples — a bare widget draws nothing
ExRatatui.draw(terminal, [
{%ExRatatui.Widgets.Paragraph{text: "hi"},
%ExRatatui.Layout.Rect{x: 0, y: 0, width: width, height: height}}
])
ExRatatui.poll_event(60_000)
end, mouse_capture: false, focus_events: false)run/2—run(fun, opts \\ []).funis arity-1, receiving a terminal reference. Opts::mouse_captureand:focus_events, both defaultfalseon the local transport — opt in explicitly when needed.draw/2—draw(terminal, [{widget, %Rect{}}, ...]). The rect, in absolute 0-based cell coordinates, says where to paint.poll_event/1—poll_event(timeout_ms \\ 250). Returns anExRatatui.Event.*struct,nilon timeout, or{:error, reason}.terminal_size/0— returns{width, height}(a tuple, not aRect).Layout.split/4—split(area, direction, constraints, opts \\ []). Returns a list of%Rect{}(one per constraint). Direction is:horizontal | :vertical; constraints are{:percentage, n},{:length, n},{:min, n},{:max, n},{:ratio, num, den},{:fill, weight}.%ExRatatui.Layout.Rect{x: 0, y: 0, width: 0, height: 0}— placement is explicit; widgets do not auto-resize to the terminal.
In an App, render/2 receives (state, %ExRatatui.Frame{width:, height:}) and returns the full [{widget, %Rect{}}] list for the whole screen — describe the entire frame every time, never a partial delta. The runtime diffs cells.
Widget index
All widgets are structs under ExRatatui.Widgets.* (a few have a companion data module under ExRatatui.*). Knowing what exists prevents reinventing it.
Text and containers
Paragraph— wrapped/aligned text with an optional block frameBlock— borders, titles (top/bottom, per-title alignment viaBlock.Title)Clear— clears a region (use under popups/overlays)
Lists and tables
List— selectable item listTable— rows, columns, header, selectionWidgetList— vertically stacked, scrollable list of primitive widgets
Progress and activity
Gauge— ratio bar with labelLineGauge— single-line ratio barSparkline— compact inline trend lineThrobber— animated spinner
Charts and drawing
BarChart— bars (Bar,BarGroupdata modules)Chart— line/scatter datasets (Chart.Axis,Chart.Dataset)Canvas— freeform drawing (Circle,Label,Line,Map,Points,Rectangle)
Navigation and selection
Tabs— horizontal tab barScrollbar— scroll position indicatorCalendar— month gridCheckbox— togglePopup— modal/overlay containerSlashCommands— command palette (SlashCommands.Command)
Input (stateful — NIF-backed)
TextInput— single-line editor; drive viaExRatatui.text_input_*helpersTextarea— multi-line editor; drive viaExRatatui.textarea_*helpers and setwrap_mode: :word_or_glyphwhen long logical lines should soft-wrap to the widget width (:noneis the default)
Rich / special
Markdown— rendered MarkdownImage— image rendering (ExRatatui.Imagedecodes; Kitty/Sixel/iTerm2/halfblocks). Resizing usesfilter: :catmull_romby default; passfilter: :nearestonly for pixel art, since it breaks thin text strokes in screenshotsBigText— oversized 8x8 pixel text (ExRatatui.BigText)CodeBlock— syntax-highlighted code (ExRatatui.CodeBlock)
App-level helpers (not widgets)
ExRatatui.Focus— focus ring for multi-panel apps.handle_key/2consumes Tab/Shift+Tab and returns{focus, key_or_nil}(nil= consumed); register rects to get click-to-focus viahandle_mouse/2; style withfocused?/2. Pure data, no process — do not hand-roll focus tracking.ExRatatui.Theme— semantic color palette (eleven slots) withdefault/0/light/0constructors andborder_style/2/text_style/2/selection_style/1helpers. Pure data threaded through render code — no globals, no automatic widget injection.ExRatatui.CellSession— render to a cell buffer instead of ANSI bytes (Phoenix LiveView, framebuffers, screenshots). Reach for it before parsing ANSI out of aSession. When the consumer owns real pixels, create it withfont_size: {w, h}:Viewport3DandImagein pixel modes then ship RGB bitmaps inDiff.regions/Snapshot.regions(ExRatatui.CellSession.Region, the complete list per frame, covered cells blank) instead of half blocks. A consumer painting a panel that is mounted on its side turns a region's bitmap withExRatatui.CellSession.Region.rotate/2(orExRatatui.Pixels.rotate_rgb8/4on the raw bytes) instead of gathering each row from a source column — it rotates the pixels only, never the cell rect. A consumer painting regions onto a panel packs them withExRatatui.Pixels.pack_rgb8/5(resample to the rect, clip, rotate and pack to:rgb565/:xrgb8888/:mono_bayer4/:rgb8in one call) instead of looping over pixels. To put an app on a pixel display, such as an e-ink panel or a Linux framebuffer, use raster_ex_ratatui rather than rasterising cells by hand.
Compose custom composite widgets in pure Elixir via the ExRatatui.Widget protocol — no Rust required. See the Custom Widgets guide.
Anti-patterns and gotchas
Highest-value rules. The guides hold the full set; these are the ones agents get wrong most.
- Key event fields are lowercase strings, never atoms — wrong values compile and silently never match. The shape is
%Event.Key{code: "up", kind: "press", modifiers: ["ctrl"]}. Character keys are their string value ("a","1"," "); special keys include"enter","esc","tab","back_tab","backspace","up"/"down"/"left"/"right","page_up"/"page_down","f1".."f12"— full table inExRatatui.Event.Key. Mouse events use stringkind/buttonthe same way (%Event.Mouse{kind: "down", button: "left", x: 0, y: 0}). - Never call
draw/2with a bare widget — pass[{widget, %Rect{}}, ...]. A widget without a rect paints nothing. - Never treat a widget as stateful — rebuild the struct each frame. Widgets are immutable view descriptors, not objects that mutate in place.
- Never return a partial scene from
render/2— return the whole screen. The runtime diffs cells; the job is to describe the full frame. - Never create
TextInput/Textareastate inrender/2— create it once inmount/1/init/1and keep the ref in state. Recreating it each render drops cursor position and typed text. - Never do I/O, HTTP, sorting, or large allocations in
render/2. It runs up to ~60fps; derive once in the transition callback and store the result in state. - Never make a blocking call in
handle_event/2/update/2. UseExRatatui.Command.async/2(reducer) orTask.Supervisor.async_nolink/2(callback). A blocking call freezes the whole UI. - Never hand-roll a render throttle to survive message bursts — the runtime already merges them. A transition that finds more messages queued defers its render, so a burst renders once with the final state; an idle message renders immediately. Don't count renders per message in tests either: drive input with
Runtime.inject_event/2, which renders synchronously. - A
[:ex_ratatui, :runtime, :mailbox]event or "messages queued" warning means the callbacks can't keep up — fix the source, don't raise the threshold. Make the slow callback cheap, sample or batch the noisy subscription, and returnrender?: falsewhere nothing visible changed.:mailbox_warn_threshold(default10_000,falsedisables) is a start option; over:ssh/:distributedit goes inside:app_opts. - Always include a catch-all
handle_event(_event, state)/update(_msg, state)returning{:noreply, state}. Unmatched events otherwise crash the app. - Never
IO.inspect/IO.puts/dbgto stdout while in raw mode — it garbles the display. Log to a file viaLogger, or useRuntime.snapshot/1. - Reducer
update/2receives{:event, event}and{:info, msg}, never bare structs. All input is routed through oneupdate/2. commands:andrender?:runtime opts are designed for the reducer runtime. They execute under the callback runtime too (command results land inhandle_info/2), but idiomatic callback apps useProcess.send_after/3or a supervised Task.- Never serve a TUI to remote users over the
:localtransport — usetransport: :sshor:distributed.:localgrabs the host tty and fails withterminal_init_failedwhere there is no TTY. - Never background or pipe stdin into a
mix run/iexTUI example. With no TTY it exits immediately or raisesterminal_init_failed; run it in a real terminal emulator. - In tests, always pass
test_mode: {w, h}andname: nil, and drive input withExRatatui.Runtime.inject_event/2.test_modedisables live TTY polling soasync: truetests do not race; named apps collide across parallel tests.
Going deeper
Full walkthroughs and the complete gotcha set live in the guides (hexdocs):
- Getting started — https://hexdocs.pm/ex_ratatui/getting_started.html
- Building UIs — https://hexdocs.pm/ex_ratatui/building_uis.html
- Callback runtime — https://hexdocs.pm/ex_ratatui/callback_runtime.html
- Reducer runtime — https://hexdocs.pm/ex_ratatui/reducer_runtime.html
- Custom widgets — https://hexdocs.pm/ex_ratatui/custom_widgets.html
- State machine patterns — https://hexdocs.pm/ex_ratatui/state_machines.html
- Testing — https://hexdocs.pm/ex_ratatui/testing.html
- Debugging — https://hexdocs.pm/ex_ratatui/debugging.html
- Performance — https://hexdocs.pm/ex_ratatui/performance.html
- Telemetry — https://hexdocs.pm/ex_ratatui/telemetry.html
- Transports — https://hexdocs.pm/ex_ratatui/transports.html
- Running over SSH — https://hexdocs.pm/ex_ratatui/ssh_transport.html
- Running over Erlang distribution — https://hexdocs.pm/ex_ratatui/distributed_transport.html
- Custom transports — https://hexdocs.pm/ex_ratatui/custom_transports.html
- Rendering to non-terminal surfaces (CellSession) — https://hexdocs.pm/ex_ratatui/cell_session.html
- Rendering to a framebuffer — https://hexdocs.pm/ex_ratatui/framebuffer_surfaces.html
- Images — https://hexdocs.pm/ex_ratatui/images.html
- Paste and clipboard — https://hexdocs.pm/ex_ratatui/paste_and_clipboard.html
- Widgets cheatsheet — https://hexdocs.pm/ex_ratatui/widgets.html