An Elixir Terminal User Interface framework inspired by Python's Textual. Build rich, interactive terminal applications with a declarative API similar to Phoenix LiveView.
- 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)
- Headless Testing - Drive an app from ExUnit with no terminal and assert on the rendered screen (see Testing)
- Minimal Dependencies - Elixir implementation with NIF-based terminal I/O
- 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.
Add drafter to your mix.exs:
def deps do
[
{:drafter, "~> 0.3"}
]
enddefmodule 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}
endhandle_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)"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() | :unlimiteduse 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. Defaultnil:styles- map of inline style overrides. Default%{}:mouse_hover-true(default) puts the terminal into hover tracking mode.falsecuts mouse event volume for apps with no hover effects:runtime- the runtime backend: a module, or the shorthand:callback,:reducer, or:shared. DefaultDrafter.Runtime.Callback, themount/render/handle_eventstyle 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:
use Drafter
state %{count: 0}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'smount/1. Default%{}:refresh_rate- frame pacing:"30fps","7.5fps",:unlimited, or a millisecond integer. Anything else raisesArgumentError. Defaults to the app'srefresh_rate/0, and"30fps"when that is not defined:clipboard-true(default) letsDrafter.Clipboard.copy/1write to the user's clipboard via OSC 52.falsemakes 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.falsere-renders on every scroll tick:syntax_highlighting-false(default).truestarts the tree-sitter servercode_viewneeds: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:rendereroverrides it, and theDRAFTER_MODEenv var overrides both:log-false(default) silences the console handler so logs cannot corrupt the display;truewritesdrafter.login the current directory; a path string writes there:level- minimum level for the file log. Default:debug. Read only when:logistrueor a path:halt_on_exit-true(default) callsSystem.halt/1once the app exits, so the return value is not observable: exit status0for:ok,1otherwise. Setfalsewhen embedding a run in a longer-lived VM
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.
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/2receives 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 —qis{:key, :q}, space is{:key, :" "}— and only codepoints above 126 arrive as{:char, codepoint}.handle_event/3receives named callbacks:nameis the atom a widget was given ason_click:,on_change:,on_submit:and so on, anddatais that widget's payload (nilfor widgets that carry none).Drafter.send_app_event/2and 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:
def handle_event(_name, _data, state), do: {:noreply, state}These are the constructors use Drafter.App imports, so they are called unqualified
from render/1.
label(text, opts)- Text displaymarkdown(content, opts)- Markdown renderingdigits(value, opts)- Large ASCII art numberssparkline(data, opts)- Mini inline chartschart(data, opts)- Line, bar, and area chartspie_chart(data, opts)- Pie chartprogress_bar(opts)- Progress indicationgauge(opts)- Dial-style value readoutmeter(opts)- Labelled value barloading_indicator(opts)- Animated spinnerrule(opts)- Horizontal/vertical dividersplaceholder(opts)- Labelled block that fills its spacestatic(content, opts)- Pre-rendered content, drawn as givenpretty(data, opts)- Inspect-style rendering of a termlog(opts)/rich_log(opts)- Append-only log viewscode_view(opts)- Syntax-highlighted source view
button(text, opts)- Clickable buttontext_input(opts)- Single-line text inputtext_area(opts)- Multi-line text editorcheckbox(label, opts)- Boolean toggleswitch(opts)- On/off switchslider(opts)- Draggable value slider, horizontal or verticalradio_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.)link(text, url_or_opts)- Clickable hyperlink
data_table(opts)- Full-featured table with sorting, selectiontree(opts)- Hierarchical data displaydirectory_tree(opts)- File system browsercalendar(opts)- Month calendarbreadcrumb(items, opts)- Path trail
vertical(children, opts)- Vertical stackhorizontal(children, opts)- Horizontal rowcontainer(children, opts)- Generic containerscrollable(children, opts)- Scrollable areasidebar(left, right, opts)- Two-column layoutsplit_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:
{:grid, [Drafter.label("a"), Drafter.label("b")], [grid_size: 2]}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
Drafter.Widget.FilePicker has no constructor — open it from an event handler with
Drafter.Widget.FilePicker.show/1.
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{: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.:normalmakesDrafter.run/2return: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.
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}
endHandlers 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.
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}
endUnlike 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.
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(3000ms),: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.
{: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.
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}
endDrafter.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 matchSelectors 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.
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}
endset_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.
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
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)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}
endits 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
endstart_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.
send_key(ctx, key, modifiers \\ [])- a key press.keyis a key atom (:enter,:up,:f1,:q,:" ");modifiersis drawn from:ctrl,:alt,:shift. Injects{:key, key}whenmodifiersis empty,{:key, key, modifiers}otherwisesend_char(ctx, char)- a{:char, codepoint}event, for codepoints above ASCII 126. Plain letters arrive from a real terminal as{:key, key}, so usesend_key/3for thosesend_click(ctx, x, y)- a left-buttonmouse_upat a zero-based cellsend_click(ctx, widget_id)- a left-buttonmouse_upat the centre of that widget's current rect. An id not in the hierarchy is ignoredsend_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}
get_state(ctx)- the app's current stateget_widget_value(ctx, widget_id)/get_widget_state(ctx, widget_id)- a widget's primary value or full state struct,nilwhen there is no such widgetquery_one(ctx, selector)/query_all(ctx, selector)- matching widget ids, using the same selector forms asDrafter.query_one/1get_widget_hierarchy(ctx)- the wholeDrafter.WidgetHierarchystruct, including:widget_rectsand:focused_widgetscreen_text(ctx)- what is on screen as plain text, one screen row per linescreen_lines(ctx)- the same rows as a list, trailing blanks removedget_rendered_output(ctx)- every byte written since start, escape sequences included
await_render(ctx, opts)- waits for a render that arrives without input, such as a timer tick.:timeoutdefaults to1000;:min_countpolls the driver's total render count instead of consuming a mailbox messagewait_for(ctx, fn ctx -> ... end, opts)- polls the function until it returns truthy.:timeoutdefaults to1000,:intervalto50. Returns:okor:timeoutassert_widget_present(ctx, selector)- returns the matched widget's id, so it can be fed straight tosend_click/2; raises when nothing matchesrefute_widget_present(ctx, selector)- raises when something matchesassert_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.
Drafter supports syntax highlighting via the tree-sitter CLI. This is entirely optional — if you don't need it, no setup is required.
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.
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.
# macOS
brew install tree-sitter
# Or via npm
npm install -g tree-sitter-cliAfter 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.
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.
Standalone scripts live under examples/, grouped by the API style they use:
examples/spark/- the flat surface:use Drafter, declarativestate, unqualified widgets, named callbacks viahandle_event/3examples/reducer/- the Elm-style runtime:use Drafter, runtime: :reducerwith a singleupdate/2message handlerexamples/internal/- theuse Drafter.Appstyle this README documents
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.exsexamples/README.md indexes all of them. To browse them in a gallery:
elixir run_examples.exsExamples 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)"- Writing Widgets & Libraries - build a custom widget and package it for reuse
- Remote TUI - serve an app over SSH or Telnet
- Large Text - the
digitsfont catalogue, how the fonts are built, and how to choose between them - Design Notes - why the internals are built the way they are; background for anyone changing them
Ctrl+Q- Quit application. The only globally handled quit key; it fires before the app's own callbacks see the eventTab- Next focusable widgetShift+Tab- Previous focusable widget- Arrow keys - Navigate within widgets
Enter- Activate/confirmEscape- 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.
MIT