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:
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.
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