Introducing lazyasdf: An Elixir-based TUI for the asdf version manager

March 06, 2023 • 11 minute read • @mitchhanbergAnalytics

lazyasdf

lazyasdf is my first real* attempt at making a TUI with Elixir!

asdf is normally used through a command line interface (CLI), lazyasdf presents you with a terminal user interface (TUI) for working with asdf.

I recently fell in love with lazygit and have since dreamed of writing my own TUI programs, but with Elixir.

The TUI provides a quick and intuitive interface for those familiar with the terminal and for those who prefer a graphical application, but the TUI is so much more approachable in my humble opinion when it comes to making your own 😄.

While I find lazyasdf to be an amazing achievement for myself, it isn't super interesting on its own. Let's dive into the specifics of how I was able to build and distribute a TUI application with Elixir.

Ratatouille

None of this would be possible if it weren't for the library ratatouille by Nick Reynolds.

I am not some genius when it comes to terminals or laying out text, this all comes from Ratatouille, which builds off of termbox, which is a [n]curses alternative.

Ratatouille leverages the Elm Architecture of which many of us have grown familiar. Let's take a look at a small Ratatouille program that showcases most of its features.

Tuido

#!/usr/bin/env elixir

Mix.install([:ratatouille])

defmodule Todos do
  @behaviour Ratatouille.App

  import Ratatouille.View
  import Ratatouille.Constants, only: [color: 1, key: 1]

  @style_selected [
    color: color(:black),
    background: color(:white)
  ]

  @space key(:space)

  @impl true
  def init(_) do
    %{
      todo: %{
        items: %{"buy eggs" => false , "mow the lawn" => true, "get a haircut" => false},
        cursor_y: 0
      }
    }
  end

  @impl true
  def update(model, msg) do
    case msg do
      {:event, %{key: @space}} ->
        {todo, _done} = Enum.at(model.todo.items, model.todo.cursor_y)

        update_in(model.todo.items[todo], & !&1)

      {:event, %{ch: ?j}} ->
        update_in(model.todo.cursor_y, &cursor_down(&1, model.todo.items))

      {:event, %{ch: ?k}} ->
        update_in(model.todo.cursor_y, &cursor_up/1)

      _ ->
        model
    end
  end

  defp cursor_down(cursor, rows) do
    min(cursor + 1, Enum.count(rows) - 1)
  end

  defp cursor_up(cursor) do
    max(cursor - 1, 0)
  end

  @impl true
  def render(model) do
    view do
      panel title: "TODO" do
        for { {t, done}, idx } <- Enum.with_index(model.todo.items) do
          row do
            column size: 12 do
              label if(idx == model.todo.cursor_y, do: @style_selected, else: []) do
                text content: "- ["
                done(done)
                text content: "] #{t}"
              end
            end
          end
        end
      end
    end
  end

  defp done(true), do: text(content: "x")
  defp done(false), do: text(content: " ")
end

Ratatouille.run(Todos)

You should be able to copy the above snippet into a file, make it executable (chmod + x) and run it!

Ratatouille calls for 3 callbacks in your TUI program, init/1, update/2, and render/1.

  • When the program boots up, the init/1 callback is called and the return value becomes your initial model state.

  • Whenever the TUI receives user input, the update/2 callback is executed with the message and your current model state.

  • When that returns, the runtime will call the render/1 callback with the new model state.

    The render/1 callback is full of macros which translate to element structs, so it's just an ergonomic DSL. Typing out many structs by hand would be a PITA!

Notes

You have probably observed that, while it is high level compared to raw termbox, Ratatouille is still sort of "low level" as an application framework.

We still have to manually track and move our cursor position, as well as index into our data structures to pull out the right data for that position.

Burrito

Now the normal problem with Elixir apps is that you have to have Elixir and Erlang on your machine to run them, as well as keep track of the version of them you have installed to make sure they are compatible, as well as write aliases to run escripts and Mix tasks, yada yada.

This is where Burrito comes in!

Burrito utilizes Zig to bundle up your application, the BEAM, and the Runtime all into one tidy executable that you can distribute at your leisure!

In the end, once we run MIX_ENV=prod mix release, Burrito will create binaries for each of our specified target platforms, and you can just copy those onto your computer and run them

The Burrito project is lead by Digit.

Homebrew

To make any program useful, it is help to be able to install it easily.

Homebrew is the primary way of accomplishing this on MacOS (my preferred operating system) and you can easily host your own collection of Homebrew packages with your own Tap!

Since lazyasdf has some quirky dependencies, the formula (what Homebrew calls a package definition) is a little interesting.

class Lazyasdf < Formula
  desc "TUI for the asdf version manager"
  homepage "https://github.com/mhanberg/lazyasdf"
  url "https://github.com/mhanberg/lazyasdf/archive/refs/tags/v0.1.1.tar.gz"
  sha256 "787da19809ed714c569c8bd7df58d55d7389b69efdf1859e57f713d18e3d2d05"
  license "MIT"

  bottle do
    root_url "https://github.com/mhanberg/homebrew-tap/releases/download/lazyasdf-0.1.1"
    sha256 cellar: :any_skip_relocation, monterey: "f489e328c19954d62284a7154fbc8da4e7a1df61dc963930d291361a7b2ca751"
  end

  depends_on "elixir" => :build
  depends_on "erlang" => :build
  depends_on "gcc" => :build
  depends_on "make" => :build
  depends_on "python@3.9" => :build
  depends_on "xz" => :build

  depends_on "asdf"

  on_macos do
    on_arm do
      resource "zig" do
        url "https://ziglang.org/download/0.10.0/zig-macos-aarch64-0.10.0.tar.xz"
        sha256 "02f7a7839b6a1e127eeae22ea72c87603fb7298c58bc35822a951479d53c7557"
      end
    end

    on_intel do
      resource "zig" do
        url "https://ziglang.org/download/0.10.0/zig-macos-x86_64-0.10.0.tar.xz"
        sha256 "3a22cb6c4749884156a94ea9b60f3a28cf4e098a69f08c18fbca81c733ebfeda"
      end
    end
  end

  def install
    zig_install_dir = buildpath/"zig"
    mkdir zig_install_dir
    resources.each do |r|
      r.fetch

      system "tar", "xvC", zig_install_dir, "-f", r.cached_download
      zig_dir =
        if Hardware::CPU.arm?
          zig_install_dir/"zig-macos-aarch64-0.10.0"
        else
          zig_install_dir/"zig-macos-x86_64-0.10.0"
        end

      ENV["PATH"] = "#{zig_dir}:" + ENV["PATH"]
    end

    ENV["PATH"] = (Formula["python@3.9"].opt_libexec/"bin:") + ENV["PATH"]

    system "mix", "local.hex", "--force"
    system "mix", "local.rebar", "--force"

    ENV["BURRITO_TARGET"] = if Hardware::CPU.arm?
      "macos_m1"
    else
      "macos"
    end

    ENV["MIX_ENV"] = "prod"
    system "mix", "deps.get"
    system "mix", "release"

    if OS.mac?
      if Hardware::CPU.arm?
        bin.install "burrito_out/lazyasdf_macos_m1" => "lazyasdf"
      else
        bin.install "burrito_out/lazyasdf_macos" => "lazyasdf"
      end
    end
  end

  test do
    # this is required for homebrew-core
    system "true"
  end
end

Here we can see all of lazyasdf's dependencies.

It requires

  • Elixir/Erlang: self-explanatory

  • asdf: self-explanatory

  • gcc,make: used to compile the termbox NIF bindings

  • Python 3.9: The termbox NIF uses a python script. For some reason it works with 3.9 and not 3.11, so I pinned it at 3.9 🤷‍♂️.

  • zig,xz: Burrito uses these two.

    Burrito specifically uses Zig 0.10.0, not 0.10.1, so we have to specify it as a resource and download it from the Zig website. Luckily, they provide pre-compiled binaries for both our our target platforms, so we can just download, untar, and add them to our PATH!

The Python dependency is even more quirky. The termbox scripts use the unversioned python executable, but Homebrew does not link those by default, so we have to manually add the unversioned one to our PATH for it to work.

Voilà!

Notes

Since this is a 3rd party Tap, the bottles that are generated are for an older version of Intel Mac, so those won't be very useful to anybody.

But if I were to merge this formula into homebrew-core, they would be bottled using the secret Homebrew GitHub Actions runners that can bottle it for all the OS's and architectures.

The Future

Ratatouille is incredible as it is today, but there is a lot of room for improvement.

As time allows, I hope to:

  • Contribute to Ratatouille to allow more complex UI features like scrollbars and dynamic size information for elements.
  • Create bindings for termbox2 (the next iteration of termbox).
  • Create a higher level toolkit for building TUIs with Ratatouille, including menus, inputs, dialogs, etc.

Thanks for reading!


* Previously, I have made a fzf clone using Ratatouille. You can find it in my dotfiles.


If you want to stay current with what I'm working on and articles I write, join my mailing list!

I seldom send emails, and I will never share your email address with anyone else.