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.
Features
- Declarative API - Phoenix LiveView-inspired component model
- Rich Widget Library - 30+ widgets including DataTable, Tree, Charts, Inputs
- Event-Driven Architecture - Keyboard, mouse, and custom events
- Flexible Layout System - Vertical, horizontal, grid, and scrollable layouts
- Multi-Screen Navigation - Push/pop screens, modals, toasts, panels
- Theming System - Built-in themes with customization support
- Animation Support - Smooth property animations with easing functions
- Zero Runtime Dependencies - Pure Elixir implementation
Requirements
- Elixir ~> 1.18
- Erlang/OTP 28 or later
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, path: "../drafter"}
]
endQuick 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({:key, :q}, _state), do: {:stop, :normal}
def handle_event({:key, :c, [:ctrl]}, _state), do: {:stop, :normal}
def handle_event(_event, state), do: {:noreply, state}
endRun your app:
mix run -e "Drafter.run(MyApp)"Core Concepts
Application Structure
Every TUI application implements the Drafter.App behaviour:
defmodule MyApp do
use Drafter.App
@callback mount(props :: map()) :: state :: map()
@callback render(state :: map()) :: component_tree :: tuple()
@callback handle_event(event :: term(), state :: map()) :: result :: term()
@callback on_ready(state :: map()) :: state :: map()
@callback on_timer(timer_id :: atom(), state :: map()) :: state :: map()
endWidget Types
Display Widgets
label(text, opts)- Text displaymarkdown(content, opts)- Markdown renderingdigits(value, opts)- Large ASCII art numberssparkline(data, opts)- Mini inline chartschart(data, opts)- Full charts (line, bar, pie)progress_bar(opts)- Progress indicationloading_indicator(opts)- Animated spinnerrule(opts)- Horizontal/vertical dividers
Input Widgets
button(text, opts)- Clickable buttontext_input(opts)- Single-line text inputtext_area(opts)- Multi-line text editorcheckbox(label, opts)- Boolean toggleswitch(opts)- On/off switchradio_set(options, opts)- Mutually exclusive optionsselection_list(options, opts)- Multi-select listoption_list(items, opts)- Single-select listmasked_input(opts)- Formatted input (phone, date, etc.)
Data Widgets
data_table(opts)- Full-featured table with sorting, selectiontree(opts)- Hierarchical data displaydirectory_tree(opts)- File system browser
Layout Widgets
vertical(children, opts)- Vertical stackhorizontal(children, opts)- Horizontal rowcontainer(children, opts)- Generic containerscrollable(children, opts)- Scrollable areagrid(children, opts)- CSS Grid-like layoutsidebar(left, right, opts)- Two-column layout
Container Widgets
card(children, opts)- Bordered cardbox(children, opts)- Simple boxcollapsible(title, content, opts)- Expandable sectiontabbed_content(tabs, opts)- Tab navigationheader(title, opts)- App headerfooter(opts)- App footer with keybindings
Event Handling
Events are handled in the handle_event/2 callback:
def handle_event(:button_clicked, _data, state) do
{:ok, %{state | clicked: true}}
end
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}
endEvent Return Values
{:ok, new_state}- Update state and re-render{:noreply, state}- No re-render needed{:stop, reason}- Exit the application{:show_modal, module, props, opts}- Display a modal{:show_toast, message, opts}- Show a toast notification{:push, module, props, opts}- Push a new screen{:pop, result}- Pop current screen
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
end2. 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/custom_action.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
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}
endScreen Types
- Default - Full-screen content
- Modal - Centered dialog with overlay
- Popover - Anchored popup
- Panel - Side panel
- Toast - Auto-dismissing notification
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, :middle_left, :middle_center, :middle_right, :bottom_left, :bottom_center, :bottom_right
Widget State Binding
Bind widget values directly to app state:
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}
endAccessing Widget State
Drafter.get_widget_value(:my_input)
Drafter.get_widget_state(:my_checkbox)
Drafter.query_one("#submit")
Drafter.query_all("Button")Timers
def on_ready(state) do
Drafter.set_interval(1000, :tick)
state
end
def on_timer(:tick, state) do
%{state | seconds: state.seconds + 1}
endAnimations
Drafter.animate(:my_widget, :opacity, 0.5, duration: 500, easing: :ease_out)
Drafter.animate(:my_label, :background, {255, 0, 0}, duration: 1000)
Available easing functions: :linear, :ease, :ease_in, :ease_out, :ease_in_out, :ease_in_quad, :ease_out_quad, :ease_in_cubic, :ease_out_cubic, :ease_in_elastic, :ease_out_elastic, :ease_in_bounce, :ease_out_bounce
Complete Example
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(placeholder: "Add todo...", bind: :new_todo, 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({:key, :q}, _state), do: {:stop, :normal}
def handle_event(_event, state), do: {:noreply, state}
endSyntax 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 in the examples/ directory can be run directly with elixir:
elixir examples/hello_world.exs
elixir examples/counter.exs
elixir examples/animation.exs
elixir examples/clock.exs
elixir examples/calculator.exs
elixir examples/charts.exs
elixir examples/widgets.exs
elixir examples/theme_sandbox.exs
elixir examples/themes.exs
elixir examples/hsl_colors.exs
elixir examples/data_table.exs
elixir examples/screens.exs
elixir examples/key_inspector.exs
elixir examples/code_browser.exs
elixir examples/syntax_highlight.exs
elixir examples/custom_loop.exs
elixir examples/custom_action.exs
Examples that are compiled into the library can be run via mix run:
mix run -e "Drafter.run(Drafter.Examples.ScreenDemo)"
mix run -e "Drafter.run(Drafter.Examples.DeclarativeSandbox)"
mix run -e "Drafter.run(Drafter.Examples.ThemeSandbox)"
mix run -e "Drafter.run(Drafter.Examples.ChartDemo)"Keyboard Shortcuts
Ctrl+CorCtrl+Q- Quit applicationTab- Next focusable widgetShift+Tab- Previous focusable widget- Arrow keys - Navigate within widgets
Enter- Activate/confirmEscape- Dismiss modals
License
MIT