A terminal UI toolkit for Elixir, in the shape of Phoenix LiveView.

You write views — modules with state, a render/2 that returns cells, and callbacks for keys, ticks and events. Atui.Runtime owns the terminal and runs the IO loop that feeds them and paints the result. Views never touch the terminal themselves, which is what makes a whole UI testable: render/2 returns a screen you can assert on without a tty in sight.

def deps do
  [{:atui, "~> 0.1.0"}]
end

A first view

defmodule Hello do
  use Atui.View

  def mount(_opts), do: {:ok, %{count: 0}}

  def tick_interval(_state), do: 1_000

  def handle_tick(tick, state), do: {:ok, %{state | count: tick}}

  def handle_key({:char, "q"}, state), do: {:halt, state}
  def handle_key(_key, state), do: {:pass, state}

  def render(state, rect) do
    Atui.Screen.new(rect.width, rect.height)
    |> Atui.Screen.box(rect, title: " hello ")
    |> Atui.Screen.put_lines_centered(rect, ["up #{state.count}s", "q to quit"])
  end
end

The UI is a child spec, so it goes under your application's own supervisor:

def start(_type, _args) do
  Supervisor.start_link([{Atui, view: Hello}], strategy: :one_for_one)
end

Adding :atui to a project starts nothing by itself — there is no application callback module in the library.

Running it

A TUI needs the VM started so Ctrl-C reaches the application instead of opening the emulator's BREAK menu. That is the +Bc flag:

elixir --erl "+Bc" -S mix run --no-halt

In a release, put +Bc in rel/vm.args.eex. mix run --no-halt works without it, but the BEAM keeps Ctrl-C for itself. Under IEx the shell owns the terminal and input stays line buffered — see Atui.Terminal for what raw mode requires and how it is obtained.

What the pieces do

Module
Atui.Runtimethe main loop: terminal, view stack, ticks, repaint
Atui.Viewthe behaviour a screen implements
Atui.Panesa tiling window manager a view can hold in its state
Atui.Screena grid of styled cells, with clipped drawing helpers
Atui.Rect / Atui.Layoutgeometry, and how to divide a region
Atui.Stylecolour and attributes for a cell
Atui.Key / Atui.Inputraw bytes decoded into key events
Atui.Terminalraw mode, alternate screen, size

Stacking and tiling

Views are stacked by the runtime: each {:push, module, opts, state} reply draws over what is below it, which is what a popup wants. The root view sees every key first — so global keys like quit live in one place — and passes on what it does not claim to the focused view.

Atui.Panes is the other arrangement: several views visible at once, tiled in a grid, each with its own ticker, focus moving between them and swappable positions. A view holds one in its state and delegates a few callbacks to it:

def mount(_opts), do: {:ok, %{panes: Panes.new(host: __MODULE__)}}

def handle_key(:tab, state), do: {:ok, %{state | panes: Panes.focus_next(state.panes)}}

def handle_key(key, state) do
  case state.panes |> Panes.handle_key(key) |> Panes.into(state) do
    {:pass, state} -> my_own_shortcuts(state, key)
    reply -> reply
  end
end

def handle_event(event, state) do
  state.panes |> Panes.handle_event(event) |> Panes.into(state)
end

def render(state, rect), do: Panes.render(state.panes, rect, &banner(state, &1))

Only writing what changed

Every event renders the stack into one frame and compares it with the last. If nothing changed, nothing is written, so an idle UI is silent on the wire. Styles stay data until that last moment: a run of equally-styled cells emits one escape sequence, and two frames differ when their styles differ, not when someone reordered the codes.

Testing a UI

:headless renders into memory instead of a terminal and :size fixes the viewport, so a test can press keys and read the frame back as text:

pid =
  start_supervised!(
    {Atui.Runtime, view: Hello, headless: true, halt: :stop, size: {40, 10}}
  )

Atui.Runtime.send_key(pid, {:char, "x"})

assert pid |> Atui.Runtime.screen() |> Atui.Screen.to_text() =~ "hello"
assert Atui.Runtime.view_state(pid, Hello).count == 0

The demo

demo/ is a small application built on Atui — a welcome screen that tiles an information popup, a clock with an ASCII analog face, and a disk-usage table beside it. It depends on the library by path, so it is also how the framework is exercised end to end.

demo/bin/demo                  # run from source, with the right VM flags

cd demo && MIX_ENV=prod mix release
_build/prod/rel/atui_demo/bin/atui_demo start

i, t and d open and close windows, arrows move focus, Ctrl with an arrow rearranges the grid, Tab cycles, ESC closes the focused window and q quits.

Tests

mix test                 # the framework
cd demo && mix test      # the demo, and the framework through it

License

Apache-2.0 — see LICENSE.