Babashka and dialog part II: Announcing the bb-dialog library

Published on 2023-01-20
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.

After our post before the break introducing dialog and how to use it from babashka, we realized that with the test code I'd been working on, I was already a good part of the way toward a first draft of wrapping dialog as a library.

So we decided to just go ahead and do that.

bb-dialog is a simple wrapper library for dialog, providing easy Clojure functions for some of the most common and useful prompts provided, plus a couple abstractions for adding your own or customizing the calls if needed.

To use it, simply add the git dependency to your bb.edn:

{:deps {com.github.pixelated-noise/bb-dialog
        {:git/tag "v0.2"
         :git/sha "66cd35803ce17c3c224348c408efe38a2acde949}}}

You will also of course need one of dialog, whiptail, or Xdialog installed on your system. By default it will prefer dialog but fall back to the others if present. The dynamic var *dialog-command* can also be rebound if needed.

The core of the library is the (dialog ...) function itself, which aims to provide a wrapper for the shell invocation with some common sense defaults, and it can be used for more manual, custom invocations (especially of additional dialogs the library does not yet provide functions for). Most of the default functions are just applications of this dialog function, for instance here is the implementation of confirm:

(defn confirm
  "Calls a confirmation dialog (`dialog --yesno`), and returns a boolean depending on whether the user agreed.

   - `title`: The title text of the dialog
   - `body`: The body text of the dialog

   Returns: boolean"
  [title body]
  (-> (dialog "--yesno" title body) :exit zero?))

Note that the dialog function returns a process map, a la babashka.process, so for the individual functions we do a little extra to pull out the return values and coerce them to more Clojure friendly results, so that for instance our confirm returns a proper boolean instead of a number, and menus and checklists return keywords or collections of keywords.

One important quirk of the API is imposed by the internal requirements of dialog itself. As a CLI program designed to work with shell, dialog basically expects everything to be stringly typed, so to speak, and this can sometimes be incompatible with our expectations. As mentioned before, we try to have sane defaults and expectations where possible, but some functions like menu offer the ability to customize the input/output coercion so you can control what you put in and what you get back. For example:

(menu "Zip Code"
      "Select the correct zip code for your area"
      {60605 "Chicago"
       10005 "New York City"
       90028 "Hollywood"}
      :in-fn str
      :out-fn #(Integer/parseInt %))


One fun upshot of all this is approach of "always return a value" means you can pretty much use these functions anywhere you'd use any other function, provided you don't mind a little impurity. For example, you could stick our confirm up there in an if:

(if (confirm "Important Confirmation!"
             "Are you sure you want to release the nukes?")


Or this one:

(swap! game-state assoc :cname (input "What is your name, little one?"
                                      "Enter your name below."))


This is all very "functionally incorrect" of course, but babashka is about getting things done, and it is hoped that this will let you and your users get things done in the shell just a little more easily and pleasantly. You can find more about all the functions currently implemented in the API docs on Github.

Putting it all together, you can do some pretty fun stuff, like this sizable example of a player creation loop from the adventure example:

(loop []
  (swap! game-state assoc :cname (input "What is your name, little one?"
                                        "Enter your name below."))

  (swap! game-state assoc :class (radiolist "What is your class?"
                                            "What has your panda trained as?
                                           Choose below."
                                            [[:WAR "Warrior" true]
                                             [:MGE "Mage" false]
                                             [:ROG "Rogue" false]]))

  (case (:class @game-state)
    :WAR (add-item! :sword)
    :MGE (add-item! :staff)
    :ROG (add-item! :knife))

  (add-item! :potion)

  (when-not (confirm "Are you happy with these choices?"
                     (str/join "\n"
                               (let [{:keys [cname class items]} @game-state]
                                 [(str "Name: " cname)
                                  (str "Class: " (name class))
                                  (str "Items: " (str/join ", " (map name items)))])))
    (swap! game-state assoc :items [])


Have fun!