How ExRatatui bridges Elixir and Rust, and how the runtime processes are laid out per transport. Nothing here is required reading for building apps — it exists for debugging, for writing custom transports, and for the curious.

The NIF bridge

ExRatatui connects Elixir to the Rust ratatui library through Rustler NIFs (Native Implemented Functions):

Elixir structs -> encode to maps -> Rust NIF -> decode to ratatui types -> render to terminal
Terminal events -> Rust NIF (DirtyIo) -> encode to tuples -> Elixir Event structs
  • Rendering: Elixir widget structs are encoded as string-keyed maps, passed across the NIF boundary, and decoded into ratatui widget types for rendering.
  • Events: The poll_event NIF runs on BEAM's DirtyIo scheduler, so event polling never blocks normal Elixir processes.
  • Terminal state: Each process holds its own terminal reference via Rust ResourceArc, supporting two backends — a real crossterm terminal and a headless test backend for CI (see Testing). The terminal is automatically restored when the reference is garbage collected.
  • Layout: Ratatui's constraint-based layout engine is exposed directly, computing split rectangles on the Rust side and returning them as %Rect{} structs.

Precompiled binaries are provided via rustler_precompiled, so depending on ex_ratatui does not require the Rust toolchain. The native library is loaded lazily on first use — compiling a project that depends on ex_ratatui does not load the NIF into the compiler VM.

Process architecture

Each transport builds on the same internal Server, a GenServer that owns the render loop and dispatches to the ExRatatui.App callbacks:

Local transport:
  Supervisor
   Server (GenServer)
         owns terminal reference (NIF)
         polls events on DirtyIo scheduler
         calls the app's mount/render/handle_event

SSH transport:
  Supervisor
  └── SSH.Daemon (GenServer, wraps :ssh.daemon)
        └── per client:
              SSH channel (:ssh_server_channel)
              ├── owns Session (in-memory terminal)
              ├── feeds client bytes through Session's ANSI parser
               Server (GenServer)
                     calls the app's mount/render/handle_event

Distributed transport:
  App node                              Client node
   Distributed.Listener               Distributed.Client (GenServer)
      DynamicSupervisor                    owns terminal reference (NIF)
          per client:                      polls events locally
               Server (GenServer)             sends events  Server
                sends widgets  Client          receives widgets  Server
   No NIF needed here

All transports provide full session isolation — each connected client gets its own Server process with independent state. The Transports guide has the cross-transport feature matrix; Custom Transports documents the contract for plugging in new ones.

  • DebuggingRuntime.snapshot/1, tracing, and common errors, including NIF rebuilds.
  • Performance — what the render loop costs and how to tune it.
  • Testing — the headless backend in practice.