Adding prompts to your Babashka scripts with dialog

Published on 2022-12-09
Pixelated Noise is a software consultancy and we're always looking for interesting projects to help out with. If you have unmet software development needs, we would be happy to hear from you.

I have recently been enlightened as to the joys of shell scripting with Babashka, and have especially enjoyed how rapid and easy it can be to build simple developer tools for projects.

I have however at times found myself wanting to add a little interactivity or easy customizability without needing huge CLI invocations or parsing EDN files and the like, and I was also curious as to what, if any, GUI/TUI options were available for Babashka. Seeing few libraries for it1, I was struck with a different approach: how do people normally go about adding UI to shell scripts in bash or zsh or the like?

The answer, is dialog.

dialog is a simple CLI tool powered by ncurses that allows you to create short-lived TUI dialog screens for use with shell scripts. It is widely used in the Linux and BSD worlds, and is likely to look pretty familiar if you've ever run any text-mode installers or ports build scripts in those. Some version of it is installed on many distros by default (or a package install away if not), but it is available as well on Brew for macOS, making it an excellent choice for an *nix OS, and even Windows via WSL.

An important feature of dialog for our purposes is that most dialogs output any choices via the exit code or stderr, which allows us to grab their return value easily from Babashka's shell on the side without blocking stdout and thus display of our shiny widgets.

An invocation for a simple confirmation dialog might look like this:

dialog --title "Important Question" --yesno "Do you like pie?" 0 0

--title will set the title of the dialog, --yesno indicates you want a confirmation dialog with the following description, and the final two strange numbers are the height and width of the ensuing dialog view. Setting these both to 0 will cause dialog to default to the size of the terminal.

Invoking this will pop up a lovely prompt like this in your terminal:

preview.jpeg

Selecting either option will end the program with an exit code according to your answer: 0 for "Yes", 1 for "No".

dialog invocations for more complicated forms can get quite cumbersome however. Here's an example of a simple menu of choices:

dialog --title "Important Question #2" \
        --menu "What is your favorite kind of pie?" \
        0 0 4 \
        "cherry" "Cherry Pie" \
        "apple" "Apple Pie" \
        "pumpkin" "Pumpkin Pie" \
        "pecan" "Pecan Pie"

Of course, we have the power of Babashka and Clojure in our hands, so naturally we can do some basic abstractions for at least simplifying the amount of code we need to write. Let's start with a simple helper function for calling dialog with babashka.process/shell:

(require '[babashka.process :refer [shell]])

(defn dialog [type title desc & args]
  (apply shell
         {:continue true
          :err :string}
         "dialog" "--clear" "--title" title type desc 0 0
         args))

You'll notice a few quirks here. First, rather than invoking shell directly, we're calling it with apply, so that we can easily have variable arguments to our dialog wrapper function; this'll be useful down the road since different dialog types use different argument structures. We also pass shell a couple of important options. :continue true tells shell not to run check against the exit code, as otherwise it will throw an exception and halt our script every time someone says no in a dialog! :err :string tells shell to convert any output over stderr to a string for easier use.

Our new Clojurized dialog will return a process map, just like usual, so to get the return values we can apply the key :exit for the exit code, and :err for any string output over stderr.

With our new helper function, we can now make a much easier function for our confirmation dialog:

(defn confirm [title desc]
  (-> (dialog "--yesno" title desc)
      :exit
      zero?))

Now our mouthful of command line becomes a much shorter one-liner that returns actual Clojure booleans!

(confirm "Important Question" "Do you like pie?")

A simple text entry box is just as simple:

(defn input [title desc]
  (-> (dialog "--inputbox" title desc)
      :err))

(input "Targeting the Nukes" "What city would you like to atomize, Mr. President?")

These are some easy examples however. What about our big wall of an invocation for that menu? That one will take a little more doing, but at least we can do it using Clojure data!

(defn menu [title desc choices]
  (->> choices
       (mapcat (fn [[k v]] [(name k) (str v)]))
       (apply dialog "--menu" title desc
             (count choices))
       :err
       keyword))

As you can see, we need to take a few extra steps here. We want choices to be a nice Clojure map, but to get from there to a list of arguments that dialog will like, we flatten our map back out into a list. dialog will return a string with the name of the option we choose, so we also call keyword on the string we get back from :err so it's easier to work with.

Now we can call our menu with a couple strings and a nice Clojure map:

(menu "Important Question #2"
      "What is your favorite kind of pie?"
      {:apple "Apple Pie"
       :cherry "Cherry Pie"
       :pumpkin "Pumpkin Pie"
       :pecan "Pecan Pie"})

Invoke this and we'll get a nicely drawn menu of options, and if we pick the obviously correct choice, get :pumpkin as our return value. Simple!

These are just a few examples of the kinds of things that dialog can do, everything from radiolists to date pickers and file dialogs, all invokable from a simple external utility. You can find more in the man page, but I also found this article of examples from the Linux Gazette most helpful for making sense of things.

There is also a graphical version of dialog, Xdialog, which is designed to be a largely drop-in replacement for dialog for systems running an X server, though it's not available on Debian-based distros or macOS.

For historical reasons, there is also a popular fork/alternate version called whiptail which uses a different library underneath and draws some things a bit differently. whiptail is installed by default instead of dialog on Debian-based systems, and supports most of the same dialog boxes, but not all (notably the date picker). Otherwise, options should largely be compatible between both, so if you want to make your scripts a little more cross-compatible out of the box across different distros, you can add some logic to check which is installed to our helper function.

(require '[babashka.fs :refer [which]])

(def DIALOG-COMMAND
  (cond
    (which "dialog") "dialog"
    (which "Xdialog") "Xdialog"
    (which "whiptail") "whiptail"
    :else (throw (Exception. "No compatible version of dialog found on this system!"))))

(defn dialog [type title desc & args]
  (apply shell
         {:continue true
          :err :string}
         DIALOG-COMMAND "--clear" "--title" title type desc 0 0
         args))

Hopefully I have whetted your appetite for what dialog is capable of, and provided enough examples and explanation for you to start adding a little more interactivity to your babashka scripts. Enjoy!

Update: There is also a Windows port of dialog! Thanks to Aleš Najmann on the Clojurians slack for finding this, and for putting it up on his own Scoop bucket for easier install. Note that this port seems to be merely an unmaintained rebuild, but any of the code here should still function just fine as it's based on dialog 1.1.

Footnotes:

1

It is possible to compile babashka with clojure-lanterna support, or use nbb with ink and reagent, but both solutions come with heavy baggage.u