Current section
Files
Jump to
Current section
Files
lib/ex_ratatui.ex
defmodule ExRatatui do
@moduledoc """
Elixir bindings for the Rust [ratatui](https://ratatui.rs) terminal UI library.
This module provides the core API for building terminal UIs: initializing
the terminal, drawing widgets, and polling for events — all via Rust NIFs
that run on the BEAM's DirtyIo scheduler.
## Quick start
ExRatatui.run(fn terminal ->
{w, h} = ExRatatui.terminal_size()
paragraph = %ExRatatui.Widgets.Paragraph{text: "Hello!"}
rect = %ExRatatui.Layout.Rect{x: 0, y: 0, width: w, height: h}
ExRatatui.draw(terminal, [{paragraph, rect}])
ExRatatui.poll_event(60_000)
end)
## Core functions
* `run/2` — initialize the terminal, run a function, restore on exit
(opts: `:focus_events`, `:mouse_capture`)
* `draw/2` — render a list of `{widget, rect}` tuples in a single frame
* `poll_event/1` — non-blocking event polling (keyboard, mouse, resize,
paste, focus)
* `terminal_size/0` — current terminal dimensions
* `set_terminal_title/1` — set the terminal window / tab title (OSC 0/2)
## Events
`poll_event/1` returns one of `ExRatatui.Event.Key`,
`ExRatatui.Event.Mouse`, `ExRatatui.Event.Resize`,
`ExRatatui.Event.Paste` (bracketed paste, on by default), or
`ExRatatui.Event.FocusGained` / `ExRatatui.Event.FocusLost` (opt in via
`run(fun, focus_events: true)`).
## Focus, theming, layout helpers
* `ExRatatui.Focus` — keyboard + mouse focus ring for multi-panel apps
* `ExRatatui.Theme` — named-slot color palette with helpers
* `ExRatatui.Layout` / `ExRatatui.Layout.Padding` — constraint-based
splitting (with Flex + `{:fill, w}` + spacing) and padding builders
## OTP apps
For supervised TUI applications, see `ExRatatui.App` — a behaviour with
LiveView-inspired callbacks (`mount/1`, `render/2`, `handle_event/2`).
## Running over SSH
To serve a TUI to remote clients without a local terminal, see
`ExRatatui.SSH.Daemon` and the [SSH transport guide](guides/transports/ssh_transport.md).
## Widgets
See `ExRatatui.Widgets.Paragraph`, `ExRatatui.Widgets.Block`,
`ExRatatui.Widgets.List`, `ExRatatui.Widgets.Table`,
`ExRatatui.Widgets.Gauge`, `ExRatatui.Widgets.LineGauge`,
`ExRatatui.Widgets.BarChart`, `ExRatatui.Widgets.Sparkline`,
`ExRatatui.Widgets.Calendar`, `ExRatatui.Widgets.Chart`,
`ExRatatui.Widgets.Tabs`,
`ExRatatui.Widgets.Scrollbar`, `ExRatatui.Widgets.Checkbox`,
`ExRatatui.Widgets.TextInput`, `ExRatatui.Widgets.Clear`,
`ExRatatui.Widgets.Markdown`, `ExRatatui.Widgets.Textarea`,
`ExRatatui.Widgets.Throbber`, `ExRatatui.Widgets.Popup`,
`ExRatatui.Widgets.BigText`, `ExRatatui.Widgets.WidgetList`,
and `ExRatatui.Widgets.Viewport3D`.
## Testing
Use `init_test_terminal/2` and `get_buffer_content/1` for headless
rendering verification in CI — no TTY required. See the
[Testing](guides/internals/testing.md) guide for widget-level and app-level
patterns, and [Debugging](guides/internals/debugging.md) for `Runtime.snapshot/1`
and tracing.
## Error handling
ExRatatui follows a predictable convention across its surface:
* **Programmer errors raise `ArgumentError`.** Invalid widget shapes,
malformed option keyword lists, callback return values the runtime
cannot interpret — these indicate a bug and fail loudly so the
supervisor surfaces them.
* **Runtime and I/O failures return `{:error, reason}` tuples.**
Terminal init, event polling, draw failures, remote-node connect
errors — anything the caller may legitimately want to retry or
branch on.
The same rule applies to `ExRatatui.App` callbacks: invalid return
shapes raise, while `mount/1` may return `{:error, reason}` to refuse
startup cleanly.
"""
require Logger
alias ExRatatui.Event
alias ExRatatui.Layout.Rect
alias ExRatatui.LocalInput
alias ExRatatui.Native
alias ExRatatui.Widgets.{
BarChart,
BigText,
Block,
Calendar,
Chart,
Checkbox,
Clear,
Gauge,
LineGauge,
List,
Markdown,
Paragraph,
Popup,
Scrollbar,
Sparkline,
Table,
Tabs,
Textarea,
TextInput,
Throbber,
Viewport3D,
WidgetList
}
@type terminal_ref :: reference()
@typedoc """
Built-in widget structs the library renders natively.
`draw/2` also accepts user-defined structs that implement the
`ExRatatui.Widget` protocol — see `t:widget/0`.
"""
@type primitive_widget ::
Paragraph.t()
| BigText.t()
| Block.t()
| Checkbox.t()
| Clear.t()
| List.t()
| Table.t()
| Gauge.t()
| LineGauge.t()
| BarChart.t()
| Sparkline.t()
| Calendar.t()
| Chart.t()
| Tabs.t()
| Scrollbar.t()
| Markdown.t()
| Popup.t()
| Textarea.t()
| TextInput.t()
| Throbber.t()
| Viewport3D.t()
| WidgetList.t()
@typedoc """
Anything `draw/2` accepts: a primitive widget struct or any struct
implementing `ExRatatui.Widget`.
"""
@type widget :: primitive_widget() | struct()
@doc """
Runs a TUI application.
Initializes the terminal, calls `fun` with the terminal reference,
and ensures terminal cleanup on exit.
ExRatatui.run(fn terminal ->
# TUI loop here
end)
For the duration of the run, the BEAM's own terminal reader is parked
so the Rust/crossterm poll path owns local input exclusively — this is
what keeps keystrokes from being dropped under fast typing when running
inside `iex`, a script, or a shell-attached release. The reader resumes
on exit. The
[Debugging guide](debugging.html#dropped-keystrokes-or-missing-characters)
covers the mechanism and the `config :ex_ratatui, detach_local_input: false`
opt-out.
## Options
* `:focus_events` — enable terminal-window focus reporting
(`%Event.FocusGained{}` / `%Event.FocusLost{}` from
`poll_event/1`). Defaults to `false`. Off by default because
enabling it leaves focus-event bytes queued in the terminal
that leak into unrelated stdin consumers (a plain shell or
`mix test` started later) when the user window-switches mid-run.
* `:mouse_capture` — enable mouse reporting (clicks, scroll, drag,
move) as `%Event.Mouse{}` from `poll_event/1`. Defaults to
`false`. When on, the terminal's native text-selection is
captured by the app; pair with `ExRatatui.Focus.handle_mouse/2`
(or a custom dispatcher) to route the events. SSH and
distributed transports decode mouse sequences regardless of this
flag because their VTE-based input parser handles them
unconditionally.
"""
@spec run((terminal_ref() -> term()), keyword()) :: term() | {:error, term()}
def run(fun, opts \\ []) when is_function(fun, 1) and is_list(opts) do
focus_events? = Keyword.get(opts, :focus_events, false)
mouse_capture? = Keyword.get(opts, :mouse_capture, false)
Native.init_terminal(focus_events?, mouse_capture?) |> do_run(fun)
end
@doc false
def do_run({:error, reason}, _fun), do: {:error, reason}
def do_run(terminal_ref, fun), do: execute_with_terminal(terminal_ref, fun)
@doc false
def execute_with_terminal(terminal_ref, fun) do
# Park the BEAM's tty reader so crossterm owns local input exclusively;
# resume it once we've handed the terminal back. See ExRatatui.LocalInput.
input = LocalInput.detach()
try do
fun.(terminal_ref)
after
safe_restore_terminal(terminal_ref)
LocalInput.reattach(input)
end
end
@doc false
def safe_restore_terminal(terminal_ref) do
Native.restore_terminal(terminal_ref)
rescue
e ->
Logger.warning("Failed to restore terminal: #{Exception.message(e)}")
end
@doc """
Draws a list of `{widget, rect}` tuples to the terminal in a single frame.
Each tuple pairs a widget struct (e.g. `%Paragraph{}`, `%Table{}`) with a
`%Rect{}` that defines where to render it. Returns `:ok` on success or
`{:error, reason}` on failure.
## Examples
iex> terminal = ExRatatui.init_test_terminal(40, 10)
iex> paragraph = %ExRatatui.Widgets.Paragraph{text: "Hello!"}
iex> rect = %ExRatatui.Layout.Rect{x: 0, y: 0, width: 40, height: 10}
iex> ExRatatui.draw(terminal, [{paragraph, rect}])
:ok
"""
@spec draw(terminal_ref(), [{widget(), Rect.t()}]) :: :ok | {:error, term()}
def draw(terminal_ref, widgets) when is_list(widgets) do
commands = ExRatatui.Bridge.encode_commands!(widgets)
Native.draw_frame(terminal_ref, commands)
end
@doc """
Polls for terminal events with a timeout (default 250ms).
Returns an `Event.Key`, `Event.Mouse`, `Event.Resize`, `Event.Paste`,
`Event.FocusGained`, or `Event.FocusLost` struct; `nil` if no event
within the timeout; or `{:error, reason}` on failure. Paste events
arrive when the terminal supports bracketed paste (enabled by
`ExRatatui.run/2` automatically); Focus events require opting in via
`ExRatatui.run(fun, focus_events: true)`.
"""
@spec poll_event(non_neg_integer()) ::
ExRatatui.Event.t() | nil | {:error, term()}
def poll_event(timeout_ms \\ 250) do
timeout_ms |> Native.poll_event() |> decode_event()
end
@doc false
def decode_event(nil), do: nil
def decode_event({:key, code, modifiers, kind}),
do: %Event.Key{code: code, modifiers: modifiers, kind: kind}
def decode_event({:mouse, kind, button, x, y, modifiers}),
do: %Event.Mouse{kind: kind, button: button, x: x, y: y, modifiers: modifiers}
def decode_event({:resize, width, height}),
do: %Event.Resize{width: width, height: height}
def decode_event({:paste, content}),
do: %Event.Paste{content: content}
def decode_event(:focus_gained), do: %Event.FocusGained{}
def decode_event(:focus_lost), do: %Event.FocusLost{}
def decode_event({:error, _} = err), do: err
@doc """
Returns the current terminal size as `{width, height}`.
Returns `{:error, reason}` if the terminal size cannot be determined.
"""
@spec terminal_size() :: {non_neg_integer(), non_neg_integer()} | {:error, term()}
def terminal_size do
Native.terminal_size() |> validate_terminal_size()
end
@doc false
def validate_terminal_size({w, h}) when is_integer(w) and is_integer(h), do: {w, h}
def validate_terminal_size({:error, _} = err), do: err
@doc """
Sets the terminal window/tab title (OSC 0/2).
Useful for daemon TUIs, dashboards, and multi-tab terminals where the
title bar should reflect the app or its current state. Best-effort:
terminals that don't honour the title escape ignore it. Returns `:ok`
or `{:error, reason}`.
## Examples
ExRatatui.set_terminal_title("ex_ratatui — dashboard")
ExRatatui.set_terminal_title("● 3 alerts")
"""
@spec set_terminal_title(String.t()) :: :ok | {:error, term()}
def set_terminal_title(title) when is_binary(title) do
Native.set_terminal_title(title)
end
@doc """
Sets the image protocol hint on a terminal reference.
When an image widget is rendered with `protocol: :auto`, this hint
decides which terminal protocol (Kitty / Sixel / iTerm2 / Halfblocks)
the render path uses. Pass `:auto` to clear the hint and fall back to
halfblocks.
Used by `ExRatatui.Distributed.attach/3` to thread the user's chosen
protocol from the remote node's `attach` opts onto the local terminal,
which is where the render actually happens for distributed sessions.
Explicit per-image protocol selections at `ExRatatui.Image.new/2` are
always honored regardless of this setting.
"""
@spec set_image_protocol(reference(), ExRatatui.Image.protocol()) :: :ok
def set_image_protocol(terminal_ref, protocol)
when is_reference(terminal_ref) and
protocol in [:auto, :halfblocks, :kitty, :sixel, :iterm2] do
Native.terminal_set_image_protocol(terminal_ref, protocol)
end
@doc """
Initializes a headless test terminal with the given dimensions.
Takes `width` (columns) and `height` (rows) for the virtual terminal.
Uses ratatui's TestBackend — no real terminal needed. Useful for testing
rendering output without a TTY. Returns a terminal reference.
## Examples
iex> terminal = ExRatatui.init_test_terminal(40, 10)
iex> is_reference(terminal)
true
iex> terminal = ExRatatui.init_test_terminal(40, 10)
iex> alias ExRatatui.Widgets.Paragraph
iex> alias ExRatatui.Layout.Rect
iex> :ok = ExRatatui.draw(terminal, [{%Paragraph{text: "Hello!"}, %Rect{x: 0, y: 0, width: 40, height: 10}}])
iex> ExRatatui.get_buffer_content(terminal) =~ "Hello!"
true
"""
@spec init_test_terminal(non_neg_integer(), non_neg_integer()) ::
terminal_ref() | {:error, term()}
def init_test_terminal(width, height) do
Native.init_test_terminal(width, height)
end
@doc """
Returns the test terminal's buffer contents as a string.
Each line is trimmed of trailing whitespace and joined with newlines.
Only works with a test terminal reference from `init_test_terminal/2`.
## Examples
iex> terminal = ExRatatui.init_test_terminal(20, 3)
iex> paragraph = %ExRatatui.Widgets.Paragraph{text: "Hi there"}
iex> rect = %ExRatatui.Layout.Rect{x: 0, y: 0, width: 20, height: 3}
iex> ExRatatui.draw(terminal, [{paragraph, rect}])
iex> ExRatatui.get_buffer_content(terminal) =~ "Hi there"
true
"""
@spec get_buffer_content(terminal_ref()) :: String.t() | {:error, term()}
def get_buffer_content(terminal_ref) do
Native.get_buffer_content(terminal_ref)
end
# -- TextInput (stateful widget) --
@doc """
Creates a new TextInput state.
Returns a reference to the Rust-side state (ResourceArc). Pass this
reference as the `:state` field of `%ExRatatui.Widgets.TextInput{}`.
## Examples
iex> state = ExRatatui.text_input_new()
iex> is_reference(state)
true
"""
@spec text_input_new() :: reference()
def text_input_new, do: Native.text_input_new()
@doc """
Forwards a key event to the TextInput state.
Pass the `code` field from an `ExRatatui.Event.Key` struct. Supports
printable characters, `"backspace"`, `"delete"`, `"left"`, `"right"`,
`"home"`, and `"end"`.
## Examples
iex> state = ExRatatui.text_input_new()
iex> ExRatatui.text_input_handle_key(state, "h")
:ok
iex> ExRatatui.text_input_handle_key(state, "i")
:ok
iex> ExRatatui.text_input_get_value(state)
"hi"
"""
@spec text_input_handle_key(reference(), String.t()) :: :ok
def text_input_handle_key(state_ref, key_code),
do: Native.text_input_handle_key(state_ref, key_code)
@doc """
Inserts a multi-character string at the TextInput cursor in one shot.
Designed as the consumer for `ExRatatui.Event.Paste` content. Control
characters (including `\\n`, `\\r`, `\\t`) are stripped — TextInput is
single-line by design. For multi-line paste support use
`textarea_insert_str/2` on a Textarea.
## Examples
iex> state = ExRatatui.text_input_new()
iex> ExRatatui.text_input_insert_str(state, "hello")
:ok
iex> ExRatatui.text_input_get_value(state)
"hello"
iex> state = ExRatatui.text_input_new()
iex> ExRatatui.text_input_insert_str(state, "line1\\nline2")
:ok
iex> ExRatatui.text_input_get_value(state)
"line1line2"
"""
@spec text_input_insert_str(reference(), String.t()) :: :ok
def text_input_insert_str(state_ref, content),
do: Native.text_input_insert_str(state_ref, content)
@doc """
Returns the current text value from the TextInput state.
## Examples
iex> state = ExRatatui.text_input_new()
iex> ExRatatui.text_input_get_value(state)
""
"""
@spec text_input_get_value(reference()) :: String.t()
def text_input_get_value(state_ref), do: Native.text_input_get_value(state_ref)
@doc """
Sets the text value on the TextInput state.
The cursor is moved to the end of the new value.
## Examples
iex> state = ExRatatui.text_input_new()
iex> ExRatatui.text_input_set_value(state, "hello")
:ok
iex> ExRatatui.text_input_get_value(state)
"hello"
"""
@spec text_input_set_value(reference(), String.t()) :: :ok
def text_input_set_value(state_ref, value),
do: Native.text_input_set_value(state_ref, value)
@doc """
Returns the current cursor position from the TextInput state.
## Examples
iex> state = ExRatatui.text_input_new()
iex> ExRatatui.text_input_cursor(state)
0
iex> ExRatatui.text_input_handle_key(state, "a")
:ok
iex> ExRatatui.text_input_cursor(state)
1
"""
@spec text_input_cursor(reference()) :: non_neg_integer()
def text_input_cursor(state_ref), do: Native.text_input_cursor(state_ref)
# -- Textarea (stateful multiline widget) --
@doc """
Creates a new Textarea state.
Returns a reference to the Rust-side state (ResourceArc). Pass this
reference as the `:state` field of `%ExRatatui.Widgets.Textarea{}`.
## Examples
iex> state = ExRatatui.textarea_new()
iex> is_reference(state)
true
"""
@spec textarea_new() :: reference()
def textarea_new, do: Native.textarea_new()
@doc """
Forwards a key event with modifiers to the Textarea state.
`modifiers` is a list of active modifier strings (e.g. `["ctrl"]`),
defaults to `[]`. The textarea supports Emacs-style shortcuts
(Ctrl+Z undo, Ctrl+Y redo, etc.).
## Examples
iex> state = ExRatatui.textarea_new()
iex> ExRatatui.textarea_handle_key(state, "h", [])
:ok
iex> ExRatatui.textarea_handle_key(state, "i", [])
:ok
iex> ExRatatui.textarea_get_value(state)
"hi"
"""
@spec textarea_handle_key(reference(), String.t(), [String.t()]) :: :ok
def textarea_handle_key(state_ref, key_code, modifiers \\ []),
do: Native.textarea_handle_key(state_ref, key_code, modifiers)
@doc """
Inserts a multi-character string at the Textarea cursor in one shot.
Designed as the consumer for `ExRatatui.Event.Paste` content. Both `\\n`
and `\\r\\n` are recognised as line breaks and produce real new lines in
the textarea; lone `\\r` is dropped. Other characters land verbatim.
## Examples
iex> state = ExRatatui.textarea_new()
iex> ExRatatui.textarea_insert_str(state, "line1\\nline2")
:ok
iex> ExRatatui.textarea_get_value(state)
"line1\\nline2"
iex> state = ExRatatui.textarea_new()
iex> ExRatatui.textarea_insert_str(state, "a\\r\\nb")
:ok
iex> ExRatatui.textarea_line_count(state)
2
"""
@spec textarea_insert_str(reference(), String.t()) :: :ok
def textarea_insert_str(state_ref, content),
do: Native.textarea_insert_str(state_ref, content)
@doc """
Returns the current text from the Textarea as a string (lines joined with \\n).
## Examples
iex> state = ExRatatui.textarea_new()
iex> ExRatatui.textarea_get_value(state)
""
"""
@spec textarea_get_value(reference()) :: String.t()
def textarea_get_value(state_ref), do: Native.textarea_get_value(state_ref)
@doc """
Sets the text value on the Textarea state.
## Examples
iex> state = ExRatatui.textarea_new()
iex> ExRatatui.textarea_set_value(state, "hello\\nworld")
:ok
iex> ExRatatui.textarea_get_value(state)
"hello\\nworld"
"""
@spec textarea_set_value(reference(), String.t()) :: :ok
def textarea_set_value(state_ref, value),
do: Native.textarea_set_value(state_ref, value)
@doc """
Returns the cursor position as `{row, col}` from the Textarea state.
## Examples
iex> state = ExRatatui.textarea_new()
iex> ExRatatui.textarea_cursor(state)
{0, 0}
"""
@spec textarea_cursor(reference()) :: {non_neg_integer(), non_neg_integer()}
def textarea_cursor(state_ref), do: Native.textarea_cursor(state_ref)
@doc """
Returns the number of lines in the Textarea state.
## Examples
iex> state = ExRatatui.textarea_new()
iex> ExRatatui.textarea_line_count(state)
1
"""
@spec textarea_line_count(reference()) :: non_neg_integer()
def textarea_line_count(state_ref), do: Native.textarea_line_count(state_ref)
# -- Encoding: Elixir structs -> string-keyed maps for NIF --
@doc false
# Internal: shared by `ExRatatui.draw/2` (local transport via
# `draw_frame`) and `ExRatatui.Session.draw/2` (per-connection
# transport via `session_draw`). Both NIFs accept the same
# `[{widget_map, rect_map}]` shape so we encode once here.
def encode_command(command), do: ExRatatui.Bridge.encode_command(command)
end