Drafter

An Elixir Terminal User Interface framework inspired by Python's Textual. Build rich, interactive terminal applications with a declarative API similar to Phoenix LiveView.

asciicast

Features

Requirements

Drafter relies on OTP 28's raw terminal mode (-noshell raw input), improved ANSI escape sequence handling, and lazy input reading. Earlier OTP versions will not handle keyboard input or screen updates correctly.

Installation

Add drafter to your mix.exs:

def deps do
[
{:drafter, "~> 0.3"}
]
end

Quick Start

defmodule MyApp do
use Drafter.App
def mount(_props) do
%{counter: 0}
end
def render(state) do
vertical([
header("My App"),
label("Counter: #{state.counter}"),
horizontal([
button("Decrement", on_click: :decrement),
button("Increment", on_click: :increment)
], gap: 2),
footer(bindings: [{"q", "Quit"}])
])
end
def handle_event(:increment, _data, state) do
{:ok, %{state | counter: state.counter + 1}}
end
def handle_event(:decrement, _data, state) do
{:ok, %{state | counter: state.counter - 1}}
end
def handle_event(_name, _data, state), do: {:noreply, state}
def handle_event({:key, :q}, _state), do: {:stop, :normal}
def handle_event({:key, :c, [:ctrl]}, _state), do: {:stop, :normal}
end

handle_event/3 handles the named callbacks the buttons emit; handle_event/2 handles raw key presses. use Drafter.App appends the catch-all handle_event/2 clause for you, but not the handle_event/3 one.

Run your app:

mix run -e "Drafter.run(MyApp)"

Core Concepts

Application Structure

Every TUI application implements the Drafter.App behaviour. mount/1 and render/1 are required; everything else is optional.

@callback mount(props :: map()) :: state :: term()
@callback render(state :: term()) :: element | [element]
@callback handle_event(event :: Drafter.Event.t(), state) ::
{:ok, state} | {:noreply, state} | {:stop, term()} | {:error, term()}
@callback handle_event(name :: atom(), data :: term(), state) ::
{:ok, state} | {:noreply, state} | {:stop, term()} | {:error, term()}
@callback on_ready(state) :: state
@callback on_timer(timer_id :: atom(), state) :: state
@callback on_message(msg :: term(), state) :: state
@callback update(props :: map(), state) :: state
@callback unmount(state) :: :ok
@callback refresh_rate() :: pos_integer() | String.t() | :unlimited

use Drafter.App imports every element constructor and the keybinding/3 macro, so vertical/2, label/2, button/2 and friends are available unqualified. It takes four options:

use Drafter is the same thing plus an import of state/1, which declares the initial state in place of writing mount/1:

use Drafter
state %{count: 0}

Starting an App

Drafter.run/2 blocks until the app exits. Every option has a default, so Drafter.run(MyApp) is a complete call:

Drafter.run(MyApp, props: %{user_id: 7}, refresh_rate: "60fps", log: "/tmp/app.log")

Returns :ok when the app stopped with {:stop, :normal} or the global Ctrl+Q, and {:error, reason} otherwise. Called from inside an app that is already running, run/2 pushes a nested session instead of starting a second terminal, always returns :ok, and honours only :props and :refresh_rate.

The two handle_event arities

handle_event/2 and handle_event/3 are separate callbacks and both are dispatched on. Which one runs depends on where the event came from:

use Drafter.App appends a catch-all handle_event/2 clause returning {:noreply, state}, so a module only writes the raw-event clauses it cares about. No catch-all is generated for handle_event/3 — a module that defines any handle_event/3 clause must also define a final clause, or an unmatched named callback raises FunctionClauseError:

def handle_event(_name, _data, state), do: {:noreply, state}

Widget Types

These are the constructors use Drafter.App imports, so they are called unqualified from render/1.

Display Widgets

Input Widgets

Data Widgets

Layout Widgets

Drafter.Widget.Grid has no constructor in Drafter.App. Place it in a render tree as the element tuple {:grid, children, opts}, where each child is a {module, props} pair such as Drafter.label/2 returns:

{:grid, [Drafter.label("a"), Drafter.label("b")], [grid_size: 2]}

Container Widgets

Drafter.Widget.FilePicker has no constructor — open it from an event handler with Drafter.Widget.FilePicker.show/1.

Event Handling

Named callbacks go to handle_event/3, raw input events to handle_event/2. Keep the clauses of each arity grouped together:

def handle_event(:button_clicked, _data, state) do
{:ok, %{state | clicked: true}}
end
def handle_event(_name, _data, state), do: {:noreply, state}
def handle_event({:key, :enter}, state) do
{:ok, state}
end
def handle_event({:key, :q}, _state) do
{:stop, :normal}
end
def handle_event({:key, :c, [:ctrl]}, _state) do
{:stop, :normal}
end

Event Return Values

Any other term returned from handle_event/3 is offered to the handlers registered with Drafter.ActionRegistry; an unrecognised term leaves the state unchanged. Any other term returned from handle_event/2 raises FunctionClauseError in the loop.

Custom Action Handlers

By default, return values from handle_event/3 are handled by Drafter's built-in dispatcher. You can extend this system without modifying any framework code by implementing the Drafter.ActionHandler behaviour.

This is the right approach for third-party widgets or plugins that introduce new action shapes — no changes to the base library required.

1. Implement the behaviour:

defmodule MyApp.DrawerHandler do
@behaviour Drafter.ActionHandler
@impl true
def handle_action({:open_drawer, id}, acc_state) do
{:ok, %{acc_state | open_drawer: id}}
end
def handle_action({:close_drawer}, acc_state) do
{:ok, %{acc_state | open_drawer: nil}}
end
def handle_action(_action, _acc_state), do: :unhandled
end

2. Register before Drafter.run/2:

Drafter.ActionRegistry.register(MyApp.DrawerHandler)
Drafter.run(MyApp)

3. Return custom actions from any event handler:

def handle_event(:open_settings, _data, _state) do
{:open_drawer, :settings}
end

Handlers are checked in registration order. Returning {:ok, new_state} stops dispatch; returning :unhandled passes control to the next handler. The built-in handler runs last and covers all standard return values.

See examples/internal/16_custom_actions.exs for a complete working example that demonstrates custom action types, state mutation, and native desktop notifications.

Screens and Navigation

Create multi-screen applications with modals and toasts:

defmodule MainScreen do
use Drafter.Screen
def mount(_props), do: %{items: []}
def render(_state) do
vertical([
label("Main Screen"),
button("Open Modal", on_click: :open_modal),
button("Show Toast", on_click: :show_toast)
])
end
def handle_event(:open_modal, _state) do
{:show_modal, MyModal, %{title: "Info"}, [width: 50, height: 15]}
end
def handle_event(:show_toast, _state) do
{:show_toast, "Hello!", [variant: :success]}
end
def handle_event(_event, state), do: {:noreply, state}
end
defmodule MyModal do
use Drafter.Screen
def mount(props), do: %{title: props.title}
def render(state) do
vertical([
label(state.title),
button("Close", on_click: :close)
])
end
def handle_event(:close, _state), do: {:pop, :closed}
def handle_event({:key, :escape}, _state), do: {:pop, :dismissed}
def handle_event(_event, state), do: {:noreply, state}
end

Unlike use Drafter.App, use Drafter.Screen does not append a catch-all. Its injected handle_event/2 default is replaced outright by the clauses a screen defines, and its injected handle_event/3 forwards to handle_event/2. A screen that omits the final handle_event(_event, state) clause above raises FunctionClauseError on the first event it does not name.

Screen Types

The type is chosen with :type in the screen's options; the default is :default, and any other value raises FunctionClauseError. Sizes are in terminal cells.

:dismissable decides who receives Escape, not what Escape does: true delivers it to that screen's handle_event/2, which must return {:pop, result} to close; false passes Escape down to the layer below untouched.

Toast Variants

{:show_toast, "Info message", [variant: :info]}
{:show_toast, "Success!", [variant: :success]}
{:show_toast, "Warning!", [variant: :warning]}
{:show_toast, "Error!", [variant: :error]}

Toast positions: :top_left, :top_center, :top_right, :bottom_left, :bottom_center, :bottom_right. Default :bottom_right, which is also what any other value falls back to.

Widget State Binding

Bind widget values directly to app state. A bound widget reads its value from the app state key each render, so render/1 never has to thread the value through itself:

def mount(_props) do
%{username: "", remember: false}
end
def render(_state) do
vertical([
text_input(placeholder: "Username", bind: :username),
checkbox("Remember me", bind: :remember),
button("Submit", on_click: :submit)
])
end
def handle_event(:submit, _data, state) do
IO.puts("Username: #{state.username}")
{:ok, state}
end

Accessing Widget State

Drafter.get_widget_value(:my_input) # the widget's primary value, or nil
Drafter.get_widget_state(:my_checkbox) # the widget's full state struct, or nil
Drafter.query_one("#submit") # the id atom of the first match, or nil
Drafter.query_all("Button") # the id atoms of every match

Selectors take three forms: a widget type as the module's last segment in CamelCase or snake_case ("Button", "TextInput", "text_input"), "#id", and ".class". Combine them without spaces to require all of them ("Button.primary", "TextInput#name"); a space separates alternatives rather than nesting them, so "Button Label" matches any button or any label.

Timers

def on_ready(state) do
Drafter.set_interval(1000, :tick)
Drafter.set_timeout(2000, :hide_banner)
state
end
def on_timer(:tick, state) do
%{state | seconds: state.seconds + 1}
end
def on_timer(:hide_banner, state) do
%{state | banner: nil}
end

set_interval/2 repeats, set_timeout/2 fires once. timer_id defaults to :tick for set_interval/2 and is required for set_timeout/2. set_interval(value, :fps) treats value as a frame rate rather than a period, and uses round(1000 / value) milliseconds.

Both must be called from the application process — inside on_ready/1, handle_event, on_timer/2, or on_message/2. An interval runs until the app stops; there is no cancel, and a second call with the same timer_id starts a second timer that also fires on_timer/2 with that id. use Drafter.App appends a catch-all on_timer/2, so unmatched ids pass the state through unchanged.

Animations

Drafter.animate(:my_widget, :opacity, 0.5, duration: 500, easing: :ease_out)
Drafter.animate(:my_label, :background, {255, 0, 0}, duration: 1000)

animate/4 returns a reference; pass it to Drafter.stop_animation/1 to end the animation early, or use Drafter.stop_all_animations/1 for every animation on a widget. Options are :duration (milliseconds, default 300), :easing (default :ease_out), and :on_complete (a zero-arity function, not run when stopped early).

Available easing functions: :linear, :ease, :ease_in, :ease_out, :ease_in_out, :ease_in_quad, :ease_out_quad, :ease_in_out_quad, :ease_in_cubic, :ease_out_cubic, :ease_in_out_cubic, :ease_in_elastic, :ease_out_elastic, :ease_in_bounce, :ease_out_bounce, :ease_in_out_bounce, :ease_in_back, :ease_out_back

Complete Example

Mix.install([{:drafter, "~> 0.1"}, {:elixir_make, "~> 0.9"}])
defmodule TodoApp do
use Drafter.App
def mount(_props) do
%{
todos: ["Learn Drafter", "Build awesome CLI apps"],
new_todo: ""
}
end
def render(state) do
todo_items =
Enum.map(state.todos, fn todo ->
label(" • #{todo}")
end)
vertical([
header("Todo App"),
scrollable(todo_items, flex: 1),
horizontal(
[
text_input(
id: :new_todo_input,
placeholder: "Add todo...",
bind: :new_todo,
on_submit: :add_todo,
keep_focus: true,
flex: 1
),
button("Add", on_click: :add_todo)
],
gap: 1
),
footer(bindings: [{"q", "Quit"}, {"Enter", "Add"}])
])
end
def handle_event(:add_todo, _data, state) do
if String.trim(state.new_todo) != "" do
{:ok, %{state | todos: state.todos ++ [state.new_todo], new_todo: ""}}
else
{:noreply, state}
end
end
def handle_event(_name, _data, state), do: {:noreply, state}
def handle_event({:key, :q}, _state), do: {:stop, :normal}
end
Drafter.run(TodoApp)

Testing

Drafter.Test runs an app headless: against an in-memory terminal, with no PTY and no real keyboard. The flow is start_headless/3 to boot, send_key/3 and the other send_* functions to drive it, get_state/1 and screen_text/1 to assert, stop/1 to shut it down.

Given this app:

defmodule Counter do
use Drafter.App
def mount(_props), do: %{count: 0}
def render(state) do
vertical([
label("Count: #{state.count}"),
button("Increment", id: :inc, on_click: :increment)
])
end
def handle_event(:increment, _data, state), do: {:ok, %{state | count: state.count + 1}}
def handle_event(_name, _data, state), do: {:noreply, state}
def handle_event({:key, :q}, _state), do: {:stop, :normal}
end

its test looks like this:

defmodule CounterTest do
use ExUnit.Case, async: false
import Drafter.Test
setup do
ctx = start_headless(Counter, %{}, size: {40, 6})
on_exit(fn -> stop(ctx) end)
%{ctx: ctx}
end
test "starts at zero", %{ctx: ctx} do
assert get_state(ctx).count == 0
assert screen_text(ctx) =~ "Count: 0"
end
test "a click increments", %{ctx: ctx} do
send_click(ctx, query_one(ctx, "Button"))
assert get_state(ctx).count == 1
assert screen_text(ctx) =~ "Count: 1"
end
test "a click by id increments too", %{ctx: ctx} do
send_click(ctx, :inc)
assert get_state(ctx).count == 1
end
end

start_headless/3 takes the app module, the props map handed to mount/1 (default %{}), and options: :size, a {columns, rows} tuple defaulting to {80, 24}, and :test_pid, the process notified on each render (default self()). It returns a context map — every other function in the module takes that context as its first argument. It raises RuntimeError if the app fails to start, and the app has completed its first render before it returns.

The headless driver is a globally registered process, so only one instance runs at a time: tests using it must be async: false, and stop/1 must run before the next one starts. Put stop/1 in on_exit/1 so a failing test still frees the services.

Every send_* function blocks until the app has finished handling the input, so a send and the assertion after it need no sleep between them.

Driving the app

Inspecting the app

Waiting and asserting

The three assertion helpers are macros, so import Drafter.Test or require Drafter.Test before calling them.

Embedding

Drafter.CellSession runs an app against an in-memory cell grid rather than a terminal, and hands you the composited screen as rows. Use it when something other than a terminal is doing the drawing — a web front end, a notebook cell, a pane inside another application — or when a host wants to own input and output itself.

session = Drafter.CellSession.start(MyApp, size: {80, 24})
Drafter.CellSession.take_cells(session) # [%Drafter.Draw.Strip{}, ...] one per row
Drafter.CellSession.take_text(session) # the same screen as plain text
Drafter.CellSession.feed_input(session, {:key, :enter})
Drafter.CellSession.resize(session, 100, 30)
Drafter.CellSession.close(session)

Each session owns unnamed services, so many run concurrently in one node without colliding. start/2 also takes :shared — a shared-state server pid — to join an existing multi-user session; every other option is passed to mount/1 as a prop.

Formatting numbers

Drafter.Format turns numbers into the short strings a digits readout has room for. It is a plain helper — call it in render/1, nothing calls it for you.

digits(Drafter.Format.compact(1_240_000)) # "1.2M"
digits(Drafter.Format.bytes(1_048_576)) # "1MB"
digits(Drafter.Format.percent(0.42, as_ratio: true)) # "42%"

examples/spark/03_digits.exs switches between all three against live values.

Syntax Highlighting

Drafter supports syntax highlighting via the tree-sitter CLI. This is entirely optional — if you don't need it, no setup is required.

If you already have tree-sitter installed

Nothing to do. Pass syntax_highlighting: true when starting your app:

Drafter.run(MyApp, syntax_highlighting: true)

Then use code_view with a file path:

code_view(path: "/path/to/file.rs", show_line_numbers: true, flex: 1)

Language is detected automatically from the file extension. Highlighting quality depends on which grammars you have installed in your tree-sitter environment.

If you don't have tree-sitter

Skip syntax_highlighting: true (or don't pass it). The code_view widget will still work — Elixir files get built-in highlighting, all other files render as plain text.

Installing tree-sitter

# macOS
brew install tree-sitter
# Or via npm
npm install -g tree-sitter-cli

After installing, set up grammars for the languages you want to highlight by following the tree-sitter getting started guide. The more grammars you have installed, the more languages code_view will highlight.

Supported in code_view

code_view(
path: state.selected_file, # preferred — tree-sitter reads the file directly
show_line_numbers: true,
flex: 1
)
code_view(
source: some_string, # also works — uses a temp file under the hood
language: :python,
flex: 1
)

When path: is given, tree-sitter reads the file directly (one system call, no temp file). When only source: is given, a temp file is created, highlighted, then deleted.

Running Examples

Standalone scripts live under examples/, grouped by the API style they use:

Each script installs the library from the checkout, so run one directly with elixir:

elixir examples/internal/01_hello_world.exs
elixir examples/internal/04_counter.exs
elixir examples/internal/07_todo.exs
elixir examples/internal/11_data_table.exs
elixir examples/internal/15_screens.exs
elixir examples/internal/16_custom_actions.exs
elixir examples/internal/17_charts.exs
elixir examples/internal/21_theme_sandbox.exs
elixir examples/internal/25_file_picker.exs
elixir examples/spark/04_counter.exs
elixir examples/reducer/04_counter.exs

examples/README.md indexes all of them. To browse them in a gallery:

elixir run_examples.exs

Two of them cover styling and layout rather than a widget:

elixir examples/spark/33_css_styling.exs
elixir examples/spark/34_breakpoints.exs

Guides

Keyboard Shortcuts

Ctrl+C is not a global quit. It is delivered to the app as {:key, :c, [:ctrl]}, and is the copy binding inside text inputs and text areas. Match it in handle_event/2 and return {:stop, :normal} if you want it to exit.

License

MIT