Current section
Files
Jump to
Current section
Files
README.md
# 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.
[](https://asciinema.org/a/uSauYhLa8aW4fHPq)
## 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
- **Remote TUI** - Serve apps over SSH or Telnet with isolated or shared sessions (see [Remote TUI](guides/remote_tui.md))
- **Headless Testing** - Drive an app from ExUnit with no terminal and assert on the rendered screen (see [Testing](#testing))
- **Embeddable** - Run an app against an in-memory cell grid instead of a terminal and render the rows yourself (see [Embedding](#embedding))
- **Minimal Dependencies** - Elixir implementation with NIF-based terminal I/O
## 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`:
```elixir
def deps do
[
{:drafter, "~> 0.3"}
]
end
```
## Quick Start
```elixir
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:
```bash
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.
```elixir
@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:
- `:css_path` - path to a CSS file used for widget styling. Default `nil`
- `:styles` - map of inline style overrides. Default `%{}`
- `:mouse_hover` - `true` (default) puts the terminal into hover tracking mode.
`false` cuts mouse event volume for apps with no hover effects
- `:runtime` - the runtime backend: a module, or the shorthand `:callback`,
`:reducer`, or `:shared`. Default `Drafter.Runtime.Callback`, the
`mount`/`render`/`handle_event` style documented here
`use Drafter` is the same thing plus an import of `state/1`, which declares the
initial state in place of writing `mount/1`:
```elixir
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:
- `:props` - map handed to the app's `mount/1`. Default `%{}`
- `:refresh_rate` - frame pacing: `"30fps"`, `"7.5fps"`, `:unlimited`, or a
millisecond integer. Anything else raises `ArgumentError`. Defaults to the app's
`refresh_rate/0`, and `"30fps"` when that is not defined
- `:clipboard` - `true` (default) lets `Drafter.Clipboard.copy/1` write to the user's
clipboard via OSC 52. `false` makes copying a no-op and drops bracketed pastes. A
keyword list sets the directions separately: `clipboard: [copy: true, paste: false]`
- `:scroll_optimization` - `true` (default) renders from the cached hierarchy during
a scroll gesture and defers a full re-render until 150 ms after the last scroll
event. `false` re-renders on every scroll tick
- `:syntax_highlighting` - `false` (default). `true` starts the tree-sitter server
`code_view` needs
- `:widget_libraries` - modules to register before the app mounts. Default `[]`
- `:mode` - global chart rendering mode: `:auto`, `:pixel`, `:kitty`, `:iterm2`,
`:sixel`, `:braille`, or `:text`. A per-widget `:renderer` overrides it, and the
`DRAFTER_MODE` env var overrides both
- `:log` - `false` (default) silences the console handler so logs cannot corrupt the
display; `true` writes `drafter.log` in the current directory; a path string
writes there
- `:level` - minimum level for the file log. Default `:debug`. Read only when `:log`
is `true` or a path
- `:halt_on_exit` - `true` (default) calls `System.halt/1` once the app exits, so the
return value is not observable: exit status `0` for `:ok`, `1` otherwise. Set
`false` when embedding a run in a longer-lived VM
```elixir
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:
- **`handle_event/2`** receives **raw input events** straight from the terminal:
`{:key, key}`, `{:key, key, modifiers}`, `{:char, codepoint}`, `{:mouse, map}`,
`{:resize, {width, height}}`. Printable ASCII 32..126 arrives as a named key —
`q` is `{:key, :q}`, space is `{:key, :" "}` — and only codepoints above 126
arrive as `{:char, codepoint}`.
- **`handle_event/3`** receives **named callbacks**: `name` is the atom a widget was
given as `on_click:`, `on_change:`, `on_submit:` and so on, and `data` is that
widget's payload (`nil` for widgets that carry none). `Drafter.send_app_event/2`
and a popped screen's result also arrive here.
`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`:
```elixir
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
- `label(text, opts)` - Text display
- `markdown(content, opts)` - Markdown rendering
- `digits(value, opts)` - Large ASCII art numbers (see [Formatting numbers](#formatting-numbers))
- `sparkline(data, opts)` - Mini inline charts
- `chart(data, opts)` - Line, bar, and area charts
- `pie_chart(data, opts)` - Pie chart
- `progress_bar(opts)` - Progress indication
- `gauge(opts)` - Dial-style value readout
- `meter(opts)` - Labelled value bar
- `loading_indicator(opts)` - Animated spinner
- `rule(opts)` - Horizontal/vertical dividers
- `placeholder(opts)` - Labelled block that fills its space
- `static(content, opts)` - Pre-rendered content, drawn as given
- `pretty(data, opts)` - Inspect-style rendering of a term
- `log(opts)` / `rich_log(opts)` - Append-only log views
- `code_view(opts)` - Syntax-highlighted source view
#### Input Widgets
- `button(text, opts)` - Clickable button
- `text_input(opts)` - Single-line text input
- `text_area(opts)` - Multi-line text editor
- `checkbox(label, opts)` - Boolean toggle
- `switch(opts)` - On/off switch
- `slider(opts)` - Draggable value slider, horizontal or vertical
- `radio_set(options, opts)` - Mutually exclusive options
- `selection_list(options, opts)` - Multi-select list
- `option_list(items, opts)` - Single-select list
- `masked_input(opts)` - Formatted input (phone, date, etc.)
- `link(text, url_or_opts)` - Clickable hyperlink
#### Data Widgets
- `data_table(opts)` - Full-featured table with sorting, selection
- `tree(opts)` - Hierarchical data display
- `directory_tree(opts)` - File system browser
- `calendar(opts)` - Month calendar
- `breadcrumb(items, opts)` - Path trail
#### Layout Widgets
- `vertical(children, opts)` - Vertical stack
- `horizontal(children, opts)` - Horizontal row
- `container(children, opts)` - Generic container
- `scrollable(children, opts)` - Scrollable area
- `sidebar(left, right, opts)` - Two-column layout
- `split_pane(children, opts)` - Panes separated by draggable dividers
`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:
```elixir
{:grid, [Drafter.label("a"), Drafter.label("b")], [grid_size: 2]}
```
#### Container Widgets
- `card(children, opts)` - Bordered card
- `box(children, opts)` - Simple box
- `collapsible(title, content, opts)` - Expandable section
- `tabbed_content(tabs, opts)` - Tab navigation
- `header(title, opts)` - App header
- `footer(opts)` - App footer with keybindings
`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:
```elixir
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
- `{:ok, new_state}` - The app claimed the event. Update state and re-render; the
event is **not** offered to the widget hierarchy
- `{:noreply, new_state}` - The app did not claim the event. Update state, then route
the event to the focused widget and its ancestors. This is what a catch-all clause
should return — returning `{:ok, state}` from a catch-all swallows every key and
click before any widget can see it
- `{:stop, reason}` - Exit the application. `:normal` makes `Drafter.run/2` return
`:ok`; any other reason makes it return `{:error, reason}`
- `{:error, reason}` - Discard the event, leaving state unchanged
- `{:show_modal, module, props, opts}` - Display a modal
- `{:show_toast, message, opts}` - Show a toast notification
- `{:push, module, props, opts}` - Push a new screen
- `{:replace, module, props, opts}` - Replace the top screen
- `{:pop, result}` - Pop current screen
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:**
```elixir
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`:**
```elixir
Drafter.ActionRegistry.register(MyApp.DrawerHandler)
Drafter.run(MyApp)
```
**3. Return custom actions from any event handler:**
```elixir
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:
```elixir
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.
- **`:default`** - Full-screen content. Takes no options; any key passed with it is
discarded
- **`:modal`** - Centered dialog with overlay. `:width`, `:height` (both `:auto`),
`:position` (`:center`), `:overlay` (`true`), `:overlay_color` (`{0, 0, 0}`),
`:overlay_opacity` (`0.5`), `:dismissable` (`true`), `:title` (`nil`), `:border`
(`true`)
- **`:popover`** - Anchored popup. `:width`, `:height` (both `:auto`), `:position`
(`{:at, 0, 0}`), `:anchor` (`nil`), `:anchor_offset` (`{0, 1}`), `:overlay`
(`false`), `:dismissable` (`true`), `:border` (`true`)
- **`:panel`** - Edge-docked side panel. `:width` (`30`), `:height` (`:full`),
`:position` (`:right`), `:overlay` (`false`), `:resizable` (`false`),
`:collapsible` (`true`)
- **`:toast`** - Auto-dismissing notification. `:width` (`40`, height is always 3
rows), `:position` (`:bottom_right`), `:duration` (`3000` ms), `:variant`
(`:info`), `:dismissable` (`true`)
`: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
```elixir
{: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:
```elixir
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
```elixir
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
```elixir
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
```elixir
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
```elixir
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:
```elixir
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:
```elixir
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
- `send_key(ctx, key, modifiers \\ [])` - a key press. `key` is a key atom
(`:enter`, `:up`, `:f1`, `:q`, `:" "`); `modifiers` is drawn from `:ctrl`, `:alt`,
`:shift`. Injects `{:key, key}` when `modifiers` is empty, `{:key, key, modifiers}`
otherwise
- `send_char(ctx, char)` - a `{:char, codepoint}` event, for codepoints above ASCII
126. Plain letters arrive from a real terminal as `{:key, key}`, so use
`send_key/3` for those
- `send_click(ctx, x, y)` - a left-button `mouse_up` at a zero-based cell
- `send_click(ctx, widget_id)` - a left-button `mouse_up` at the centre of that
widget's current rect. An id not in the hierarchy is ignored
- `send_mouse(ctx, event)` - a raw mouse event map: `%{type: :mouse_down | :mouse_up
| :drag, button: button, x: x, y: y}`, `%{type: :move, x: x, y: y}`, or
`%{type: :scroll, direction: :up | :down | :left | :right, x: x, y: y}`
### Inspecting the app
- `get_state(ctx)` - the app's current state
- `get_widget_value(ctx, widget_id)` / `get_widget_state(ctx, widget_id)` - a
widget's primary value or full state struct, `nil` when there is no such widget
- `query_one(ctx, selector)` / `query_all(ctx, selector)` - matching widget ids,
using the same selector forms as `Drafter.query_one/1`
- `get_widget_hierarchy(ctx)` - the whole `Drafter.WidgetHierarchy` struct, including
`:widget_rects` and `:focused_widget`
- `screen_text(ctx)` - what is on screen as plain text, one screen row per line
- `screen_lines(ctx)` - the same rows as a list, trailing blanks removed
- `get_rendered_output(ctx)` - every byte written since start, escape sequences
included
### Waiting and asserting
- `await_render(ctx, opts)` - waits for a render that arrives without input, such as
a timer tick. `:timeout` defaults to `1000`; `:min_count` polls the driver's total
render count instead of consuming a mailbox message
- `wait_for(ctx, fn ctx -> ... end, opts)` - polls the function until it returns
truthy. `:timeout` defaults to `1000`, `:interval` to `50`. Returns `:ok` or
`:timeout`
- `assert_widget_present(ctx, selector)` - returns the matched widget's id, so it can
be fed straight to `send_click/2`; raises when nothing matches
- `refute_widget_present(ctx, selector)` - raises when something matches
- `assert_widget_value(ctx, selector, expected)` - raises when the selector matches
nothing or the value differs
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.
```elixir
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)
```
- `take_cells(session)` - the full grid as `Drafter.Draw.Strip` structs, one per
screen row, each a list of styled segments. This is what a host renders
- `take_cells_diff(session)` - only the rows that changed since the last call, as
`{row_index, strip}` tuples, with an updated session. Redraw those rows alone
rather than the whole grid
- `take_lines(session)` / `take_text(session)` - the screen as plain text, styling
dropped and trailing blanks trimmed. The same view `Drafter.Test.screen_lines/1`
gives, for logging or asserting rather than rendering
- `feed_input(session, event)` - inject an input event: `{:key, key}`,
`{:char, codepoint}`, `{:mouse, map}`
- `resize(session, columns, rows)` - resize the surface; the app re-renders
- `close(session)` - release the processes the session owns
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.
```elixir
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%"
```
- `compact(number)` - a magnitude suffix, `k`/`M`/`B`/`T`. Below 1000 there is no
suffix; a fractional result keeps one decimal place
- `bytes(number)` - a byte count in powers of 1024, suffixed `B`, `KB`, `MB`, `GB`
or `TB`
- `percent(number, opts)` - a percentage. `as_ratio: true` treats the input as
`0.0..1.0`; `:decimals` sets the precision
`examples/spark/03_digits.exs` switches between all three against live values.
## Syntax Highlighting
Drafter supports syntax highlighting via the [`tree-sitter`](https://tree-sitter.github.io/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:
```elixir
Drafter.run(MyApp, syntax_highlighting: true)
```
Then use `code_view` with a file path:
```elixir
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
```bash
# 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](https://tree-sitter.github.io/tree-sitter/). The more grammars you have installed, the more languages `code_view` will highlight.
### Supported in code_view
```elixir
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:
- `examples/spark/` - the flat surface: `use Drafter`, declarative `state`,
unqualified widgets, named callbacks via `handle_event/3`
- `examples/reducer/` - the Elm-style runtime: `use Drafter, runtime: :reducer` with
a single `update/2` message handler
- `examples/internal/` - the `use Drafter.App` style this README documents
Each script installs the library from the checkout, so run one directly with
`elixir`:
```bash
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:
```bash
elixir run_examples.exs
```
Two of them cover styling and layout rather than a widget:
```bash
elixir examples/spark/33_css_styling.exs
elixir examples/spark/34_breakpoints.exs
```
## Guides
- [Writing Widgets & Libraries](guides/writing_widgets.md) - build a custom widget and package it for reuse
- [Remote TUI](guides/remote_tui.md) - serve an app over SSH or Telnet
- [Large Text](guides/large_text.md) - the `digits` font catalogue, how the fonts are built, and how to choose between them
- [Design Notes](guides/design_notes.md) - why the internals are built the way they are; background for anyone changing them
## Keyboard Shortcuts
- `Ctrl+Q` - Quit application. The only globally handled quit key; it fires before
the app's own callbacks see the event
- `Tab` - Next focusable widget
- `Shift+Tab` - Previous focusable widget
- Arrow keys - Navigate within widgets
- `Enter` - Activate/confirm
- `Escape` - Dismiss a dismissable modal or popover
`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