Current section
Files
Jump to
Current section
Files
README.md
# DrafterAn 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))- **Minimal Dependencies** - Elixir implementation with NIF-based terminal I/O## Requirements- Elixir ~> 1.18- Erlang/OTP 28 or laterDrafter 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.## InstallationAdd `drafter` to your `mix.exs`:```elixirdef deps do [ {:drafter, "~> 0.1"} ]end```## Quick Start```elixirdefmodule 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}end```Run your app:```bashmix run -e "Drafter.run(MyApp)"```## Core Concepts### Application StructureEvery TUI application implements the `Drafter.App` behaviour:```elixirdefmodule 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()end```### Widget Types#### Display Widgets- `label(text, opts)` - Text display- `markdown(content, opts)` - Markdown rendering- `digits(value, opts)` - Large ASCII art numbers- `sparkline(data, opts)` - Mini inline charts- `chart(data, opts)` - Full charts (line, bar, pie)- `progress_bar(opts)` - Progress indication- `loading_indicator(opts)` - Animated spinner- `rule(opts)` - Horizontal/vertical dividers#### 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- `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.)#### Data Widgets- `data_table(opts)` - Full-featured table with sorting, selection- `tree(opts)` - Hierarchical data display- `directory_tree(opts)` - File system browser#### Layout Widgets- `vertical(children, opts)` - Vertical stack- `horizontal(children, opts)` - Horizontal row- `container(children, opts)` - Generic container- `scrollable(children, opts)` - Scrollable area- `grid(children, opts)` - CSS Grid-like layout- `sidebar(left, right, opts)` - Two-column layout#### 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### Event HandlingEvents are handled in the `handle_event/2` callback:```elixirdef handle_event(:button_clicked, _data, state) do {:ok, %{state | clicked: true}}enddef handle_event({:key, :enter}, state) do {:ok, state}enddef handle_event({:key, :q}, _state) do {:stop, :normal}enddef handle_event({:key, :c, [:ctrl]}, _state) do {:stop, :normal}end```#### Event 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 HandlersBy default, return values from `handle_event/3` are handled by Drafter's built-indispatcher. You can extend this system without modifying any framework code byimplementing the `Drafter.ActionHandler` behaviour.This is the right approach for third-party widgets or plugins that introduce newaction shapes — no changes to the base library required.**1. Implement the behaviour:**```elixirdefmodule 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: :unhandledend```**2. Register before `Drafter.run/2`:**```elixirDrafter.ActionRegistry.register(MyApp.DrawerHandler)Drafter.run(MyApp)```**3. Return custom actions from any event handler:**```elixirdef handle_event(:open_settings, _data, state) do {:open_drawer, :settings}end```Handlers are checked in registration order. Returning `{:ok, new_state}` stopsdispatch; returning `:unhandled` passes control to the next handler. The built-inhandler runs last and covers all standard return values.See `examples/16_custom_actions.exs` for a complete working example that demonstratescustom action types, state mutation, and native desktop notifications.### Screens and NavigationCreate multi-screen applications with modals and toasts:```elixirdefmodule 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]} endenddefmodule 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}end```### Screen Types- **Default** - Full-screen content- **Modal** - Centered dialog with overlay- **Popover** - Anchored popup- **Panel** - Side panel- **Toast** - Auto-dismissing notification### 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`, `:middle_left`, `:middle_center`, `:middle_right`, `:bottom_left`, `:bottom_center`, `:bottom_right`### Widget State BindingBind widget values directly to app state:```elixirdef mount(_props) do %{username: "", remember: false}enddef render(state) do vertical([ text_input(placeholder: "Username", bind: :username), checkbox("Remember me", bind: :remember), button("Submit", on_click: :submit) ])enddef handle_event(:submit, _data, state) do IO.puts("Username: #{state.username}") {:ok, state}end```### Accessing Widget State```elixirDrafter.get_widget_value(:my_input)Drafter.get_widget_state(:my_checkbox)Drafter.query_one("#submit")Drafter.query_all("Button")```### Timers```elixirdef on_ready(state) do Drafter.set_interval(1000, :tick) stateenddef on_timer(:tick, state) do %{state | seconds: state.seconds + 1}end```### Animations```elixirDrafter.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```elixirMix.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({:key, :q}, _state), do: {:stop, :normal} def handle_event(_event, state), do: {:noreply, state}endDrafter.run(TodoApp)```## Syntax HighlightingDrafter 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 installedNothing to do. Pass `syntax_highlighting: true` when starting your app:```elixirDrafter.run(MyApp, syntax_highlighting: true)```Then use `code_view` with a file path:```elixircode_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-sitterSkip `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# macOSbrew install tree-sitter# Or via npmnpm 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```elixircode_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 ExamplesStandalone scripts in the `examples/` directory can be run directly with `elixir`:```bashelixir examples/01_hello_world.exselixir examples/02_clock.exselixir examples/03_digits.exselixir examples/04_counter.exselixir examples/05_calculator.exselixir examples/06_collapsible.exselixir examples/07_todo.exselixir examples/08_animation.exselixir examples/09_hsl_colors.exselixir examples/10_themes.exselixir examples/11_data_table.exselixir examples/12_key_inspector.exselixir examples/13_syntax_highlight.exselixir examples/14_code_browser.exselixir examples/15_screens.exselixir examples/16_custom_actions.exselixir examples/17_charts.exselixir examples/18_chart_perf.exselixir examples/19_gauge.exselixir examples/20_multi_series_charts.exselixir examples/21_theme_sandbox.exselixir examples/22_skin_sandbox.exselixir examples/23_split_pane.exselixir examples/24_dashboard.exselixir examples/25_file_picker.exselixir examples/26_braille_area.exs```Examples that are compiled into the library can be run via `mix run`:```bashmix 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+C` or `Ctrl+Q` - Quit application- `Tab` - Next focusable widget- `Shift+Tab` - Previous focusable widget- Arrow keys - Navigate within widgets- `Enter` - Activate/confirm- `Escape` - Dismiss modals## LicenseMIT