ExRatatui brings the Rust ratatui terminal UI library to Elixir: widgets, layout, key and mouse events, and supervised TUI apps that run in a local terminal, over SSH or across distributed Erlang. Ascii.ExRatatui makes frames into ratatui widgets, so the components (a file tree, meters, gauges, a boot log) sit in your terminal UI beside ratatui's own, and Ascii.ExRatatui.Viewer is a ready-made app that plays pieces full screen. ExRatatui is an optional dependency of this library: add it to your project to have both. Written against ExRatatui 0.17.

# needs: mix.exs
{:ascii, "~> 0.3.0"},
{:ex_ratatui, "~> 0.17"}

The viewer

In a terminal:

mix run -e 'Ascii.ExRatatui.Viewer.run(piece: "donut")'

Space pauses, ← and → step through the pieces, q quits. It takes the pieces to step through, options for the first, and how to draw colour:

# needs: a real terminal
Ascii.ExRatatui.Viewer.run(pieces: ~w(donut elixir matrix-rain), mode: :ansi256)

With interactive: true, keys go to a piece that can be used first (a file tree's arrows, a form's typing), and those it has no use for to the viewer:

# needs: a real terminal
Ascii.ExRatatui.Viewer.run(piece: "file-tree", options: %{walk: false}, interactive: true)

Coloured pieces want a terminal with 24-bit colour, which most modern ones have; mode: :ansi256 takes the nearest of xterm's 256 colours instead. Scenes are 200 columns wide: in a smaller window the viewer shows their middle.

Frames as widgets

A frame is a widget, so [{frame, rect}] in render/2 draws it from the top left of rect. Ascii.ExRatatui.place/3 gives it a rect of its own size, centred in an area, in a titled box if you like:

alias ExRatatui.Layout.Rect

{:ok, frame} = Ascii.still("elixir")
area = %Rect{x: 0, y: 0, width: 80, height: 40}
{paragraph, rect} = Ascii.ExRatatui.place(frame, area, title: "elixir")
rect
#=> %ExRatatui.Layout.Rect{x: 20, y: 5, width: 40, height: 30}

Too big for its area, a frame fills it and shows its middle. For text of your own composing, Ascii.ExRatatui.lines/2 gives the frame as ratatui lines, a span to each run of one colour. All three take mode: :ansi256.

An app of your own

ExRatatui.App gives a LiveView-like app: mount/1, render/2, handle_event/2 and handle_info/2. An Ascii.Player keeps the time and sends the app each frame, so render/2 only places the latest:

defmodule MyApp.ArtTUI do
  use ExRatatui.App

  alias ExRatatui.Event.Key
  alias ExRatatui.Layout.Rect

  @impl true
  def mount(opts) do
    {:ok, _player} = Ascii.Player.start_link(piece: Keyword.get(opts, :piece, "donut"))
    {:ok, %{frame: nil}}
  end

  @impl true
  def render(%{frame: nil}, _size), do: []

  def render(%{frame: frame}, size) do
    area = %Rect{x: 0, y: 0, width: size.width, height: size.height}
    [Ascii.ExRatatui.place(frame, area, title: "art")]
  end

  @impl true
  def handle_info({:ascii_frame, _ref, frame}, state), do: {:noreply, %{state | frame: frame}}
  def handle_info(_message, state), do: {:noreply, state}

  @impl true
  def handle_event(%Key{code: "q", kind: "press"}, state), do: {:stop, state}
  def handle_event(_event, state), do: {:noreply, state}
end

Start it under a supervisor, {MyApp.ArtTUI, piece: "night-coast"}, or from a script with MyApp.ArtTUI.start_link(piece: "donut"). In a standalone script, add a terminate/2 that calls System.stop(0) so quitting ends the program.

Keys and clicks

A piece that can be used (see Keys and clicks) takes ExRatatui's events through Ascii.ExRatatui.handle_event/3: a key by its code, and a left click as the frame's cell under it, wherever place/3 put the picture (its box, centring and scroll allowed for). Keep the animation in your state rather than a player, and what was chosen is in its options:

alias ExRatatui.Event.{Key, Mouse}
alias ExRatatui.Layout.Rect

tree = Ascii.new!("file-tree", options: %{walk: false, paths: ["lib/app.ex", "mix.exs"], root: "app"})
{picture, tree} = Ascii.frame(tree, 0.0)
placed = Ascii.ExRatatui.place(picture, %Rect{x: 0, y: 0, width: 80, height: 30}, title: "files")

{:ok, tree} = Ascii.ExRatatui.handle_event(tree, %Key{code: "down", kind: "press"}, placed)
tree.options.cursor
#=> "lib/"

# A click on the row of mix.exs, where it is on the screen.
{_paragraph, rect} = placed
{:ok, tree} = Ascii.ExRatatui.handle_event(tree, %Mouse{kind: "down", button: "left", x: rect.x + 15, y: rect.y + 3}, placed)
tree.options.cursor
#=> "mix.exs"

In an ExRatatui.App, call it from handle_event/2 with the placed your render/2 used (keep the screen's size in state from %ExRatatui.Event.Resize{} events, or lay the screen out the same way in both), and start the app with mouse_capture: true for clicks. Ascii.ExRatatui.event/2 gives the piece's event alone, for a key you want to look at first.

A live dashboard

The UI and data pieces take your own data (see Live data), so they make dashboard widgets: a sparkline of samples, a gauge of a reading, a progress bar, an htop-style cpu-meters. Keep each as an animation, take new data up with Ascii.put_options!/2 as it comes, and draw on a timer. Here, the VM's memory and process count, sampled every second, one under the other:

defmodule MyApp.Dashboard do
  use ExRatatui.App

  alias ExRatatui.Event.Key
  alias ExRatatui.Layout
  alias ExRatatui.Layout.Rect

  @series %{label: "memory", unit: " mb", values: []}

  @impl true
  def mount(_opts) do
    memory =
      Ascii.new!("sparkline", options: %{series: [@series], range: false, cols: 60, rows: 3})

    procs =
      Ascii.new!("gauge",
        options: %{label: "processes", unit: "", max: 1000, value: :erlang.system_info(:process_count)}
      )

    send(self(), :sample)
    send(self(), :tick)
    {:ok, %{memory: memory, procs: procs, samples: [], frames: [], t: 0.0}}
  end

  @impl true
  def render(state, size) do
    area = %Rect{x: 0, y: 0, width: size.width, height: size.height}
    heights = for {_title, art} <- state.frames, do: {:length, art.rows + 2}
    rects = Layout.split(area, :vertical, heights ++ [{:min, 0}])

    for {{title, art}, rect} <- Enum.zip(state.frames, rects),
        do: Ascii.ExRatatui.place(art, rect, title: title)
  end

  # New data: taken up by each piece, drawn on the next tick.
  @impl true
  def handle_info(:sample, state) do
    mb = div(:erlang.memory(:total), 1_048_576)
    samples = Enum.take(state.samples ++ [mb], -60)
    memory = Ascii.put_options!(state.memory, series: [%{@series | values: samples}])
    procs = Ascii.put_options!(state.procs, value: :erlang.system_info(:process_count))
    Process.send_after(self(), :sample, 1000)
    {:noreply, %{state | memory: memory, procs: procs, samples: samples}, render?: false}
  end

  def handle_info(:tick, state) do
    t = state.t + 0.1
    {memory_frame, memory} = Ascii.frame(state.memory, t)
    {procs_frame, procs} = Ascii.frame(state.procs, t)
    Process.send_after(self(), :tick, 100)
    frames = [{"memory", memory_frame}, {"processes", procs_frame}]
    {:noreply, %{state | memory: memory, procs: procs, frames: frames, t: t}}
  end

  @impl true
  def handle_event(%Key{code: "q", kind: "press"}, state), do: {:stop, state}
  def handle_event(_event, state), do: {:noreply, state}
end

The gauge's needle swings from its last reading to each new one; the sparkline scrolls as samples come. Pieces with a size of their own (the sparkline's cols and rows here) give frames of that size, so the layout follows art.rows. ratatui already writes only the cells that change between draws.

A piece is just a {widget, rect} in the list render/2 returns, so it can sit beside lists, tables and ratatui's own gauges in any ExRatatui.Layout.

Colouring parts

Pieces that know their parts (a file tree's folders and files, a boot log's statuses, a meter's bars; see Ascii.Parts) take a colour for each with :parts, so a widget can match the rest of your UI: files by type, warnings in your warning colour. Pass it to Ascii.new/2 or the player, or parts: true for the colours each piece suggests. Parts given no colour are drawn in the terminal's own.

parts = %{"folder" => "#58a6ff", "file.md" => "#d29922", "branch" => "#8b949e"}
{tree, _} = Ascii.new!("file-tree", parts: parts) |> Ascii.frame(3.0)

[_root, _docs, guide | _] = Ascii.ExRatatui.lines(tree)
Enum.map(guide.spans, &{String.trim(&1.content), &1.style.fg})
#=> [{"│   ├──", {:rgb, 139, 148, 158}}, {"guide.md", {:rgb, 210, 153, 34}}]

Over SSH and across nodes

An app can be served over SSH, each client getting its own session (and so its own animation), or attached to from another node. The viewer serves as it is; give each transport its own child id:

# needs: an SSH host key in /data/ssh
children = [
  Supervisor.child_spec(
    {Ascii.ExRatatui.Viewer,
     transport: :ssh, port: 2222, system_dir: "/data/ssh", app_opts: [piece: "matrix-rain"]},
    id: :art_over_ssh
  ),
  Supervisor.child_spec({Ascii.ExRatatui.Viewer, transport: :distributed}, id: :art_over_dist)
]

Then ssh -p 2222 host. system_dir holds the daemon's host key (make one with ssh-keygen -t ed25519 -N "" -f /data/ssh/ssh_host_ed25519_key), and the other options of OTP's :ssh.daemon/2, such as :user_dir and :pwdfun, say who may log in. This is a good fit for a Nerves device.

Testing

ExRatatui's test_mode: {width, height} runs an app headlessly, and ExRatatui.Runtime.inject_event/2 drives keys, so an art app is tested like any other:

alias ExRatatui.Event.Key
alias ExRatatui.Runtime

{:ok, pid} = MyApp.ArtTUI.start_link(piece: "spinners", test_mode: {60, 30}, name: nil)
Process.sleep(300)
Runtime.snapshot(pid).render_count > 1
#=> true
Runtime.inject_event(pid, %Key{code: "q", kind: "press"})
#=> :ok

To see what was drawn, draw it into an ExRatatui.CellSession and read the cells back:

alias ExRatatui.CellSession

session = CellSession.new(38, 28)
:ok = CellSession.draw(session, [{frame, %Rect{x: 0, y: 0, width: 38, height: 28}}])
cells = CellSession.take_cells(session).cells
Enum.at(cells, 1 * 38 + 17).symbol
#=> "_"