ASCII components and diagrams for Elixir: file trees, boot logs, htop-style meters, gauges, sparklines, progress bars, forms, terminals, calendars and departure boards that draw your own live data, natively, in Phoenix LiveView, terminal UIs, Livebook and Nerves devices, in your app's colours. 191 animated pieces in all: alongside the components, scenes, logos, distros, 3D shapes, simulations and type.

   0[|||||||||||||||||||||98.0%]    4[|||||||||||||||####  73.5%]
   1[|||||#               23.7%]    5[|||||||||||||||##### 75.9%]
   2[|||||||||||||||||##  74.9%]    6[||||||||||||||||||###86.2%]
   3[|||||||||||||||||### 75.1%]    7[||||||||||||||||||###91.8%]
 Mem[|||||||||||****6.51G/15.5G]  Tasks: 142, 421 thr; 7 running
 Swp[||              141M/2.00G]  Load average: 3.29 1.70 1.08
                                  Uptime: 4 days, 03:12:45

   PID USER      VIRT   RES S  CPU% MEM%     TIME+  Command
 20379 app      1.19G  350M R 497.8  2.2  25:38.59  indexer
 19941 root     2.85G  191M S  29.0  1.2   9:54.68  server
 21514 app      2.29G  551M S  29.0  3.5  15:08.45  backup
  • Components that take live data. The UI, data and type pieces take your data as options (readings, listings, text), and new data as it comes (Ascii.put_options/2): a gauge's needle swings to the new value, a sparkline scrolls on, a file tree lists your files. See Live data.
  • Native to the Elixir ecosystem. Function components and LiveView players for Phoenix (live_player/1 takes your assigns), widgets for ExRatatui terminal UIs, locally or over SSH, ANSI for any terminal, a supervised Ascii.Player for a process of your own, and plain frame data for Livebook, a framebuffer or anything else. No NIFs, no Node at runtime, and no required dependencies; the optional ExRatatui integration brings ratatui's NIF only if you add it.
  • Usable. A file tree's cursor walks and its folders open, a form's fields take typing, a calendar picks a date: keys and clicks go to the piece (Ascii.handle_event/2), in LiveView, in ExRatatui and in a plain terminal, and what was chosen is in its options. A click tells you the part under it, the file or the bar (Ascii.part_at/4).
  • In your colours. Give a piece an ink, one colour or a gradient (Ascii.Ink), and colour its parts one by one: a file tree's folders and file types, a boot log's OK and WARN, a meter's bars, a heading's letters (Ascii.Parts). On a page the colours can be CSS variables, so they follow your theme into dark mode; on paper, the suggested colours are those for a light page.
  • Put together. Frames go into bordered panels, beside and above one another, with rules between (Ascii.Compose), for a dashboard on a plain terminal or a device's screen; out as SVG for a README or an email (Ascii.Render.SVG, mix ascii.svg); and mix ascii.top watches your BEAM, htop-style, made of the pieces themselves.
  • Native diagrams. Build flowcharts, sequence diagrams and C4 views from Elixir data or supported Mermaid source (Ascii.Diagram, Ascii.Mermaid). The result is a frame for the same renderers, with nested boundaries and sequence fragments, optional colours per element, and retained geometry for hit testing. See the diagram guide for the syntax support matrix and runnable examples.
  • Accessible. Each component says what it shows, from its data, for screen readers ("load: 72%", "deploy: 6 steps, 1 failed"), and the Phoenix players label the picture with it (Ascii.describe/1).
  • Exact. It began as a port of ascii.rest by @bas3line (bas3line/ascii, a TypeScript library for web pages), and every piece draws exactly what upstream draws, character for character and colour for colour: each is tested frame by frame against golden output generated from upstream's own source, on macOS and Linux. PORTING.md has the story.

Installation

Requires Elixir 1.18 or later.

From Hex, with the docs on HexDocs:

# in mix.exs
def deps do
  [
    {:ascii, "~> 0.3.0"},
    # optional, for the Phoenix components and the LiveView player:
    {:phoenix_live_view, "~> 1.1"},
    # optional, for ratatui widgets and the full-screen terminal viewer:
    {:ex_ratatui, "~> 0.17"},
    # optional (Phoenix brings it), to feed the pieces your :telemetry events:
    {:telemetry, "~> 1.0"}
  ]
end

Version 0.3.0 renames ascii_art to ascii and AsciiArt.* to Ascii.*. Existing consumers should follow the migration guide.

Quick start

One frame

# A component, from your data: a gauge reading 72%, two seconds in.
IO.puts(Ascii.render!("gauge", 2.0, options: %{label: "cpu", value: 72}))

# Or art: the picture at 1.5 seconds, as text.
IO.puts(Ascii.render!("donut", 1.5))

{:ok, text} = Ascii.render("typewriter", 0.0, options: %{prefix: "we make "})
true = text =~ "we make "

# What a piece is.
meta = Ascii.meta!("night-coast")
{200, 100, :scenes} = {meta.cols, meta.rows, meta.category}
"night-coast" in Ascii.pieces(category: :scenes)

Playing frames

Pieces are functions of time with state (simulations step by how far t moved), so play time forward through Ascii.frame/3:

{:ok, anim} = Ascii.new("doom-fire")
{frame, anim} = Ascii.frame(anim, 0.0)
{frame, _anim} = Ascii.frame(anim, 1 / 24)

# An Ascii.Frame: text, lines, and for coloured pieces a palette index a cell.
18 = length(frame.lines)
nil = frame.colors

# A coloured piece gives cols * rows palette indices, row by row.
{logo, _} = Ascii.new!("elixir") |> Ascii.frame(0.0)
true = byte_size(logo.colors) == logo.cols * logo.rows
"#" <> _ = Ascii.Frame.color_at(logo, 20, 10)

# Or a lazy stream, one frame every 1/fps seconds.
Ascii.stream!("spinners", fps: 12) |> Enum.take(3) |> Enum.map(& &1.t)

Options are checked against the piece's defaults: unknown keys and values of the wrong kind are {:error, {:invalid_option, key}}, string keys and values (from params) are cast, and numbers are clamped. Nothing from outside ever becomes an atom.

In a terminal

mix ascii.play night-coast            # q or Ctrl-C to stop
mix ascii.play elixir --mode ansi256
mix ascii.play typewriter --option "prefix=we make " --paper
mix ascii.play big-text --ink ff5f6d,ffc371   # one ink, or a gradient
mix ascii.play boot-log --parts               # its parts in colour
mix ascii.play file-tree --option walk=false --interactive   # arrows, enter, space
mix ascii.top                                 # this BEAM, htop-style
mix ascii.svg gauge --option value=72 --parts # one frame as an SVG

With ExRatatui as a dependency, a full-screen viewer steps through every piece (space pauses, ← and → for the next, q quits), in your terminal or served over SSH, and frames are ratatui widgets for your own terminal UIs:

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

From code, Ascii.Player plays a piece in real time and hands each frame to a process, a callback, or any number of subscribers; under a supervisor it sends only to its callback and subscribers. Ascii.Render.ANSI turns a frame into truecolor (or 256-colour, or plain) escapes:

alias Ascii.{Player, Render.ANSI}

draw = fn frame -> IO.write([ANSI.home(), ANSI.render(frame, mode: :truecolor)]) end

IO.write([ANSI.hide_cursor(), ANSI.clear()])
{:ok, player} = Player.start_link(piece: "matrix-rain", callback: draw)
Process.sleep(500)
:ok = Player.pause(player)
:ok = Player.set_fps(player, 10)
:ok = Player.resume(player)
Player.stop(player)
IO.write([ANSI.reset(), ANSI.show_cursor(), "\n"])

Live data

The components draw your own data: progress bars, sparklines, a gauge, an htop-style panel, uptime bars, a contribution heatmap, candlesticks, a bar chart, an equalizer, a file tree, a terminal session, a departures board. Give it as options, and new data while it plays with Ascii.put_options/2 (or Ascii.Player.set_options/2):

cpu = %{label: "cpu", unit: "%", lo: 0, hi: 100, values: [12, 30, 25, 60]}
anim = Ascii.new!("sparkline", options: %{series: [cpu], range: false, rows: 3})

anim = Ascii.put_options!(anim, series: [%{cpu | values: cpu.values ++ [45]}])
{frame, _anim} = Ascii.frame(anim, 1.0)
IO.puts(frame.text)

Getting started lists what each takes. Ascii.Telemetry turns your app's :telemetry events into readings for them, and Ascii.BEAM your VM's schedulers, memory and busiest processes.

Usable components

A component that can be used takes keys and clicks: a file tree's cursor moves and its folders open, a form's fields take typing, a calendar picks a date. Everything it changes is in its options, so what was chosen is there to read:

paths = ["lib/my_app.ex", "lib/my_app/repo.ex", "mix.exs", "README.md"]
tree = Ascii.new!("file-tree", options: %{walk: false, paths: paths, root: "my_app"})

{:ok, tree} = Ascii.handle_event(tree, {:key, "down"})
{:ok, tree} = Ascii.handle_event(tree, {:key, "right"})
{:ok, tree} = Ascii.handle_event(tree, {:key, "down"})
"lib/my_app.ex" = tree.options.cursor
"file tree of my_app: 2 folders, 4 files; at lib/my_app.ex" = Ascii.describe(tree)

# Which part is under a click: here, the file the cursor is on.
{frame, tree} = Ascii.frame(tree, 0.0)
{"file.ex", "my_app.ex"} = Ascii.part_at(tree, frame, 18, 2)

In LiveView, give the player interactive (and notify to be told of each key and click); in ExRatatui, Ascii.ExRatatui.handle_event/3 takes its events; in a terminal, mix ascii.play --interactive.

Putting frames together

alias Ascii.Compose

{:ok, gauge} = Ascii.still("gauge", options: %{label: "load", value: 72}, parts: true)
{:ok, log} = Ascii.still("boot-log", options: %{title: "deploy", steps: [%{text: "built", status: "ok"}]})

board =
  Compose.above([
    Compose.beside([Compose.panel(gauge, title: "load"), Compose.panel(log, title: "deploy")]),
    Compose.divider(128, label: "end of report", style: :dashed)
  ])

IO.puts(Ascii.Render.ANSI.render(board))
svg = IO.iodata_to_binary(Ascii.Render.SVG.render(board, background: "#0d1117"))
true = String.starts_with?(svg, "<svg")

Phoenix: a still

Ascii.Phoenix.Components.ascii/1 draws one frame (frame 0 by default, which upstream designs to be a good still) as a <pre>: plain text for text pieces, runs of coloured spans over the ground for coloured ones. No JavaScript. It is role="img" with an aria-label.

<Ascii.Phoenix.Components.ascii piece="donut" />
<Ascii.Phoenix.Components.ascii piece="night-coast" label="a lighthouse at night" />
<Ascii.Phoenix.Components.ascii piece="elixir" mono class="logo" />
<Ascii.Phoenix.Components.ascii piece="big-text" ink={["#ff5f6d", "#ffc371"]} />
<Ascii.Phoenix.Components.ascii piece="file-tree" parts={%{"folder" => "#58a6ff"}} />

Phoenix: playing

Ascii.Phoenix.player/1 embeds Ascii.Phoenix.PlayerLive with live_render/3: a LiveView of its own, with its own timer, so the page needs no handle_info. The dead render is frame 0; once connected it plays.

<Ascii.Phoenix.player socket={@socket} id="spinners" piece="spinners" />
<Ascii.Phoenix.player socket={@socket} id="logo" piece="elixir" fps={20} />
<Ascii.Phoenix.player socket={@socket} id="coast" piece="night-coast" />

For live data, Ascii.Phoenix.live_player/1 plays a piece inside your LiveView and takes new options each time it renders with them: the gauge's needle swings to each new reading.

<Ascii.Phoenix.live_player id="load" piece="gauge" options={%{label: "load", value: @load}} />
<Ascii.Phoenix.live_player id="top" piece="cpu-meters" options={@readings} parts />

parts colours each part of a component as it suggests (htop's green and red bars here), or in your colours: parts={%{"bar.user" => "#22c55e"}}, or your theme's: parts={%{"bar.user" => "var(--color-success)"}}.

interactive gives a player keys and clicks; notify sends your LiveView each one, with the part clicked and the options the piece now has:

<Ascii.Phoenix.live_player id="files" piece="file-tree" options={%{walk: false, paths: @paths}} interactive notify />

With the hook below registered, the browser draws, and the server sends as little as it can. A piece that closes a loop (60 of them, every logo among them) is drawn for one cycle and sent once, compressed, a few kilobytes for a logo; the browser plays it from then on and the server does nothing more. Any other piece sends each frame as the runs of cells that changed, nothing when none did, and nothing while the picture is off screen or its tab hidden. Without the hook (or with render={:server}) the server patches the HTML a row at a time instead. See the Phoenix guide.

The hook

The only JavaScript in the project, priv/static/ascii_hook.js. Register it in your app.js:

import {Ascii} from "../../deps/ascii/priv/static/ascii_hook.js"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: {Ascii},
  params: {_csrf_token: csrfToken},
})

examples/demo.exs is a gallery of every piece, a whole Phoenix app in one file: elixir examples/demo.exs, then open http://localhost:4000. The index shows frame 0 of each piece by category (text pieces through Components.ascii/1, coloured ones on a canvas); each opens a page that plays it with player/1, where you can try its options, paper, one ink, the ink's colour, and colours for its parts.

Nerves and other devices

Ascii.Player needs nothing from Phoenix. On a device with a serial console, play into the terminal as above. With a display, draw the frame's cells yourself: frame.lines holds the characters and frame.colors a palette index for each, row by row.

defmodule MyDevice.Art do
  use GenServer

  def start_link(piece), do: GenServer.start_link(__MODULE__, piece)

  @impl true
  def init(piece) do
    {:ok, player} = Ascii.Player.start_link(piece: piece, fps: 10)
    {:ok, %{player: player, palette: nil}}
  end

  @impl true
  def handle_info({:ascii_frame, _ref, frame}, state) do
    for {line, y} <- Enum.with_index(frame.lines),
        {char, x} <- Enum.with_index(String.codepoints(line)),
        char != " " do
      color = Ascii.Frame.color_at(frame, x, y) || "#ffffff"
      # draw `char` at cell (x, y) in `color` on your display
      {x, y, char, color}
    end

    {:noreply, state}
  end
end

{:ok, _} = MyDevice.Art.start_link("ubuntu")

Guides

  • Getting started: pieces, frames, options, live data for the components, keys and clicks, words for screen readers, your colours (ink, parts, CSS variables), paper and one ink, clocks, and what things cost.
  • Components at a glance: every component, the data it takes, its size, parts and keys, and what they all share.
  • Phoenix and LiveView: stills, live players, live data with live_player/1, interactive players, telemetry, what goes over the wire, one player for many viewers.
  • Terminal apps: mix ascii.play, mix ascii.top, ANSI colour, writing only what changed, panels and layouts with Ascii.Compose, Ascii.Player, scripts.
  • ExRatatui: the full-screen viewer, pieces as widgets in your own terminal UI, locally or over SSH, keys and clicks, and a live dashboard.
  • Livebook: stills and animations in a notebook with Kino.
  • Nerves: the console, SSH, supervised players, and pixels for a framebuffer or an SPI panel.
  • Drawing frames yourself: cells, runs of colour, SVG (Ascii.Render.SVG), asciinema recordings, frames over the wire.
  • Writing a piece: your own pieces, on the same contract.

Pieces

Each slug is upstream's file name; Ascii.meta!/1 says what a piece shows and which options it takes.

categorypiecesslugs
scenes13alpine-dawn, aurora-fjord, deep-reef, desert-night, earthrise, kyoto-dusk, marine-drive, misty-forest, night-coast, ocean-sunset, storm-plains, taj-dawn, varanasi-ghats
shapes12cube, dna-helix, donut, glxgears, gyroscope, heart, icosahedron, mobius-strip, spring, tesseract, torus-knot, twisted-ring
space11black-hole, earth, eclipse, galaxy, moon-phases, planet, rocket, saptarishi, solar-system, starfield, three-body
physics14bouncing-balls, chladni, double-pendulum, falling-sand, flag, fountain, harmonograph, lorenz, newtons-cradle, pendulum-wave, plucked-string, pond-ripples, smoke, wave-interference
nature16aurora, bonsai, campfire, cherry-blossom, contour-map, fern, fireflies, fractal-tree, landscape, lightning, rain, ruled-mountains, sea-swell, snowfall, sunrise, wind
creatures10aquarium, butterfly, cat, fox, jellyfish, owl, snake, spider, starlings, whale
objects14analog-clock, candle, coffee, ferris-wheel, hawa-mahal, hourglass, kite, lava-lamp, lighthouse, skyline, sundial, train, vinyl, windmill
generative13epicycles, flow-field, glider-gun, hilbert-curve, julia-set, langtons-ant, mandelbrot, maze, plasma, reaction-diffusion, rule-30, sierpinski, voronoi
effects8doom-fire, fireworks, matrix-rain, rotozoomer, sparks, synthwave, tunnel, tv-static
ui12boot-log, box-frames, calendar, digital-clock, dividers, file-tree, form-controls, not-found, progress-bar, skeleton, spinners, terminal
data10bar-chart, candlesticks, cpu-meters, equalizer, gauge, heartbeat, heatmap, radar, sparkline, uptime-bar
type9big-text, dissolve, glitch, marquee, morse, scramble, split-flap, typewriter, wave-text
logos27c, clojure, cpp, csharp, css, dart, elixir, erlang, go, haskell, html, java, javascript, julia, kotlin, lua, ocaml, perl, php, python, r, ruby, rust, scala, swift, typescript, zig
distros22almalinux, alpine-linux, arch-linux, centos, debian, deepin, elementary-os, endeavouros, fedora, gentoo, kali-linux, linux-mint, manjaro, nixos, opensuse, pop-os, red-hat, rocky-linux, tux, ubuntu, void-linux, zorin-os

PORT_STATUS.md lists every piece with its frame time and porting notes.

How it matches upstream

  • Ascii.JSMath reproduces JavaScript's numbers where they differ from Elixir's, bit for bit: Math.round, %, Math.fround, typed-array stores, Math.hypot, Math.cbrt, Math.log1p, toFixed and String(number). Ascii.Int32 does JS's 32-bit operators. sin, exp, pow and the rest are Erlang's :math, which can differ from V8 in the last bit but has never changed a golden frame.
  • test/fixtures/golden/ holds frames from upstream, generated with Node at fixed times and played at the frame rate, ink and paper, in colour and one ink. Every piece is tested against them (Ascii.GoldenCase), and against upstream's contract (Ascii.ContractCase).
  • PORTING.md lists every place JavaScript and Elixir differ and how the port handles it, and every deviation from upstream (there are few, all for unusual options).

Contributing a piece

A piece is a module implementing Ascii.Piece: meta/0, init/1 (the options, merged with the defaults; precompute here) and frame/3 (the picture at t for an Ascii.Env, its colours, and the next state). The contract is upstream's: every frame exactly rows lines of cols characters from printable ASCII, ·, ° and U+2500–U+259F (scenes also • and ●); deterministic for the same t; colours, when asked for, a palette index for every cell. See Ascii.Piece for an example.

To port an upstream piece:

  1. Have upstream at the commit in UPSTREAM.md in tmp/upstream, and its golden fixture in test/fixtures/golden/ (NODE=/path/to/x64/node mix ascii.golden <slug>).
  2. Write test/pieces/<slug>_test.exs with use Ascii.ContractCase and use Ascii.GoldenCase, and watch it fail.
  3. Port the piece until it passes, using Ascii.JSMath, Ascii.Int32 and the helpers in Ascii.Kit; PORTING.md has the hazards to check.
  4. mix run tools/gen_registry.exs registers it; mix ascii.perf <slug> times it against upstream's budget; mix precommit checks everything.

Development

mix deps.get
mix test                 # everything but the frame-time budgets
mix test --only perf     # the budgets
mix precommit            # what CI runs
mix ascii.perf       # frame times of every piece

Tool versions are pinned in .tool-versions. Regenerating fixtures needs Node; see UPSTREAM.md.

Licence

MIT, © @bas3line for ascii.rest, and the authors of this port. The logos and distros are drawn from devicon (MIT) and Simple Icons (CC0); each is a trademark of its owner, shown to name the language or the distribution. See LICENSE and THIRD_PARTY_NOTICES.md.