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 — and Atui.Runtime runs the IO loop that feeds them and paints the result. Views never touch the terminal, so a whole UI can be driven and asserted on without a tty in sight.

Installation

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

Start it from your application's supervision tree:

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

Running it

A TUI needs the VM started so that Ctrl-C reaches the application rather than opening the emulator BREAK menu. That is the +Bc flag:

elixir --erl "+Bc" -S mix run --no-halt      # from source
# or, in a release's rel/vm.args.eex:
+Bc

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 actually requires.

The pieces

Views are stacked by the runtime: each push draws over what is below it, which is what a popup wants. Atui.Panes is the other arrangement — several views visible at once, tiled, each with its own ticker.

Driving a view from outside

Views react to keys and ticks on their own. Anything else — a job finishing, a file changing, a message from another process — arrives as an event:

Atui.send_event(:refresh)                  # to the view with focus
Atui.send_event_to(Atui.Runtime, MyView, {:rows, rows})

Sending the runtime process a plain message does the same thing, so a Task reply or a subscription needs no adapter.

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 Atui.Runtime.screen(pid) |> Atui.Screen.to_text() =~ "hello"

Summary

Functions

The child spec to put the UI under a supervisor.

Sends an event to the view with focus. See Atui.Runtime.send_event/2.

Starts the UI loop with view as the root view.

The version of Atui in use — for an application to put in a title bar.

Functions

child_spec(opts)

The child spec to put the UI under a supervisor.

Supervisor.start_link([{Atui, view: MyView}], strategy: :one_for_one)

:temporary — a UI that has quit has nothing to restart into, and restarting it would take the terminal back from whatever the user returned to.

send_event(runtime \\ Atui.Runtime, event)

Sends an event to the view with focus. See Atui.Runtime.send_event/2.

send_event_to(runtime, module, event)

Sends an event to a named view. See Atui.Runtime.send_event_to/3.

start(opts \\ [])

Starts the UI loop with view as the root view.

Accepts every option Atui.Runtime.start_link/1 takes; :view is required.

version()

The version of Atui in use — for an application to put in a title bar.