A counter app, running in your terminal, using the four callbacks every Raxol app implements.
Generate a new project:
mix raxol.new my_app
cd my_app
mix deps.getOr add to an existing project:
# mix.exs
def deps do
[{:raxol, "~> 2.7"}]
endThe tutorial app below needs a real terminal, but building and testing Raxol
does not. Prerequisites: Elixir/OTP (versions in the repo's mise.toml)
and a C toolchain for the termbox2 NIF (make + cc; on Debian/Ubuntu,
apt-get install build-essential). From a fresh clone:
mix local.hex --force # fresh machines and CI: install Hex without a prompt
mix deps.get
mix compile # builds the termbox2 NIF
SKIP_TERMBOX2_TESTS=true MIX_ENV=test mix test --exclude slow --exclude integration --exclude docker
MIX_ENV=test mix raxol.rate # RATE: render-determinism golden suiteSKIP_TERMBOX2_TESTS=true excludes the tests that need a real local terminal;
CI sets the same variable. Plain mix test without the exclude flags also
runs integration suites that need external services (PostgreSQL for the
workflow checkpoint tests), so stick to the command above. If HOME is
read-only in your sandbox, point MIX_HOME and HEX_HOME at a writable
directory first.
Every Raxol app follows The Elm Architecture (TEA) with four callbacks:
defmodule MyApp do
use Raxol.Core.Runtime.Application
# 1. Initialize state
@impl true
def init(_context) do
%{count: 0}
end
# 2. Handle messages
@impl true
def update(message, model) do
case message do
:increment ->
{%{model | count: model.count + 1}, []}
:decrement ->
{%{model | count: model.count - 1}, []}
# Keyboard events
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "="}} ->
{%{model | count: model.count + 1}, []}
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "-"}} ->
{%{model | count: model.count - 1}, []}
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "q"}} ->
{model, [Directive.stop()]}
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "c", ctrl: true}} ->
{model, [Directive.stop()]}
_ ->
{model, []}
end
end
# 3. Render UI from state
@impl true
def view(model) do
column style: %{padding: 1, gap: 1, align_items: :center} do
[
text("My Counter", style: [:bold]),
box style: %{border: :single, padding: 1, width: 20, justify_content: :center} do
text("Count: #{model.count}", style: [:bold])
end,
row style: %{gap: 1} do
[
button("=", on_click: :increment),
button("-", on_click: :decrement)
]
end,
text("Press =/- or click buttons. q to quit.", style: [:dim])
]
end
end
# 4. Subscriptions (optional)
@impl true
def subscribe(_model), do: []
# Run the app in this terminal until it quits
def start do
{:ok, pid} = Raxol.start_link(__MODULE__, [])
ref = Process.monitor(pid)
receive do
{:DOWN, ^ref, :process, ^pid, _reason} -> :ok
end
end
endinit/1returns a plain map, which is your entire app stateupdate/2pattern-matches on messages and returns{new_state, commands}. The empty list[]means "no side effects"view/1builds the UI from state using the View DSL macros (column,row,box)Directive.stop()tells the runtime to shut down (theDirectivealias comes fromuse Raxol.Core.Runtime.Application)start/0starts the app and waits for it to quit. It lives inside the module becausemix compileruns any code inlib/that sits outside one
Save as lib/my_app.ex and run:
mix run -e "MyApp.start()" +---> view(model) ---> Terminal
|
init(context) --+--> model
|
+---> update(message, model) --+
^ |
| {new_model, cmds} |
+------------------------+
init/1sets up your initial state (the "model")view/1renders the UI; it's called after every state changeupdate/2handles messages (keyboard events, button clicks, timers)subscribe/1sets up recurring events (timers, external data)
State flows in one direction. Views are pure functions of state. Side effects go through commands.
The View DSL provides macros for building layouts:
# Layout containers
column style: %{gap: 1} do ... end # Vertical stack
row style: %{gap: 2} do ... end # Horizontal stack
# Components
text("Hello", style: [:bold]) # Text with styling
button("Click", on_click: :msg) # Clickable button
text_input(value: v, placeholder: "") # Text input
progress(value: 65, max: 100) # Progress bar
# Containers
box style: %{border: :single, padding: 1} do ... end # Bordered box
# Utilities
divider() # Horizontal line
spacer() # Flexible spaceUse subscribe/1 to get periodic messages:
@impl true
def subscribe(_model) do
[subscribe_interval(1000, :tick)] # Send :tick every second
end
@impl true
def update(:tick, model) do
{%{model | uptime: model.uptime + 1}, []}
endUse --sup when generating to get a proper OTP application:
mix raxol.new my_app --supThis generates an Application module with a supervision tree. Run with:
mix run --no-haltA Raxol app puts your terminal into raw mode, and full-screen apps also switch to
the alternate screen. Both get restored on the way out. But if the app dies hard,
a kill -9 or a VM crash, nothing runs that restore and you land back in a shell
with no echo and no line editing.
Nothing is broken. Reset it:
resetIf you cannot see what you are typing, that still works blind. Type it and hit
Enter. stty sane is the lighter version, and it fixes echo without clearing the
screen.
vim and tmux leave the same mess when you SIGKILL them. It comes with the territory for full-screen terminal programs. Why OTP covers what can take the VM down that way in the first place.
That counter is a complete Raxol app. init/update/view is the whole API, and
everything else builds on this loop.
- Component Gallery: all Components with examples
- Core Concepts: buffers, the rendering pipeline, and how they fit together
- Building Apps: state machines, scrollable lists, keyboard shortcuts
mix raxol.playground browses 40 Component demos interactively, with search and
filtering.
SSH serving. Serve your app over SSH. Each connection gets its own process:
mix run examples/ssh/ssh_counter.exs
# Then: ssh localhost -p 2222Hot code reload. Edit your view function while the app is running:
iex -S mix run examples/dev/hot_reload_demo.exs
# Edit the view/1 function and save; UI updates automaticallyCrash isolation. Components run in separate processes. One crash doesn't take down the app:
mix run examples/components/process_component_demo.exsWorking examples to study:
examples/getting_started/counter.exs: the counter from this pageexamples/demo.exs: flagship demo with dashboard, sparklines, live statsexamples/getting_started/todo_app.exs: a keyboard-driven todo list app