Building an interactive CLI menu with Haskell and Brick

Alex Scriba
10 min readMay 25, 2023

--

Screenshot of finished menu (working gif at bottom of article)

I have recently been working on a little CLI tool in haskell to help with quickly jumping to recently opened projects in the terminal when working with neovim, to speed up my personal workflow, and to practice my haskell with a project I would actually use (here is the repo if you are interested: svim). Part of this project was to make a simple interactive menu that allows you to scroll and select a project to open. I found an amazing package, called Brick that allows you to do just that. It lets you build a fast and simple (or complicated if you want) interactive CLI interface.

The problem was that the Haskell community still tends to be comparatively small, and Brick being used for a small use case, I was trying to use a niche package in a niche language. On top of that, the few articles and tutorials I did find, were outdated with respect to the latest changes to the framework. Using the combination of the outdated tutorials and the new documentation I was able to piece things together to build the menu I wanted. Given the process was not as straightforward as I would have hoped, I thought I would write this article to help anyone in a similar predicament.

Prerequisites

Admittedly, I am still a relative beginner to Haskell, having learnt it from a book (Learn you a Haskell for great good) and then working on some personal projects. With that knowledge, a little help and some googling I was able to figure this out. So a basic understanding of Haskell is required, but nothing too intense. The latest Brick release uses an implementation of the MonadState type, and recommends using the Lens package to interact with the state, and so I would recommend some basic understanding of MonadState (and or Monad Transformers) and lenses, but I will try give a simple explanation on how to use them here.

I will also be using Haskells Cabal build tool, so make sure you have that installed.

The Goal

In this article I will show you how to build a simple interactive menu that runs in your terminal. It is built completely in haskell and uses the Brick package. It allows you to simply display your options and scroll between them using the arrow keys.

Setup

To get started lets make our project. In your desired directory run:

cabal init

This will create the basic project structure. Important to us is the app/Main.hs file where our code will go, and the .cabal file.

Lets configure the cabal project. So in your cabal file add the following lines, where the executable is configured:

executable brick-example
main-is: Main.hs

build-depends:
base ^>=4.14.3.0
, template-haskell -- Allows us to make lenses
, lens -- The lens package itself
, brick -- The Brick package
, vty -- A dependency of Brick we need to expose
, cursor -- A dependency we need to build the menu
hs-source-dirs: app
default-language: Haskell2010
ghc-options: -threaded -- This is required by brick to function

In the code above, we added the necesary dependencies and added the ghc options to run build the executable with multithreading enabled. This is required by Brick to function correctly.

Save that file and install those packages with:

cabal install

Building the Menu

Your app/Main.hs file should look like this:

module Main where

main :: IO ()
main = putStrLn "Hello, Haskell!"

Let’s add all the imports we will need to build this. Of course you can also add those as you go along.

import Brick (
App,
BrickEvent (..),
EventM,
attrName,
continueWithoutRedraw,
defaultMain,
halt,
showFirstCursor,
str,
withAttr,
)
import Brick.AttrMap (attrMap)
import Brick.Main (App (..))
import Brick.Types (Widget)
import Brick.Util (on)
import Brick.Widgets.Core (vBox)
import Control.Lens (makeLenses, use)
import Control.Lens.Getter (view)
import Control.Lens.Operators ((.=))
import Cursor.Simple.List.NonEmpty
import Data.List.NonEmpty (nonEmpty)
import Graphics.Vty (Event, Key (..), black, white)
import Graphics.Vty.Attributes (defAttr)
import Graphics.Vty.Input (Event (..))
import System.Exit (die)

Now let’s define some type aliases and data types.

type ResourceName = String

type MenuOption = String

data MenuState = MenuState
{ _options :: NonEmptyCursor MenuOption
}

makeLenses ''MenuState

The ResourceName type alias, is simply to put in places where brick requires a resource name.

The MenuOption type is what we are going to keep in our menu and display, if you wanted to you could make it more complex and store some more information with each menu item. This would just mean a more complex drawing logic later on, and I wanted to keep things simple for this tutorial.

The MenuState data type, is the state we are going to pass around our application. If you have ever used Elm or state management such as Redux in front end web development, this is like the type of your ‘global’ state. For our purposes all we need in this state is our options.

The options are stored in a NonEmptyCursor of MenuOptions. The NonEmptyCursor (imported from the cursor package) is used to store the options, as it also keeps track of which item is selected (like a cursor). It more or less translated to a type of:

data NonEmptyCursor a =
NonEmptyCursor
{ nonEmptyCursorPrev :: [a] -- items before selected (in reverse order)
, nonEmptyCursorCurrent :: a
, nonEmptyCursorNext :: [a] -- the items after selected

Next in our type defenitions we use the makeLenses function. This is where we start getting into lenses. A lens is simply a fancy method of accessing properties in records (over simplified explanation). In general it takes a property thats name starts with a _ and makes a lense for that property with the same name, minus the underscore. So in our case a lens is made with the name ‘options’. However, to use the makeLenses function we need to add the Template Haskell language directive to our project. At the top of the file add:

{-# LANGUAGE TemplateHaskell #-}

This allows for functions such as makeLenses to run at compile time. Once that is done we could use lenses as follows:

main :: IO ()
main = do
state <- ... -- example state

let menuOptions = view options state -- returns the options from the state
changedState = set options someNewValue -- returns a copy of the state with the _options changed
...

Now that we have our types sorted out, we can move on to Brick. For those that have used Elm before, brick has a similar architecture. It comes in 3 parts: an initial state, a function that changes state based on our input, and a function that tells brick how to render our app. To easily pass state around to all those functions, Brick uses an implementation of the MonadState monad, that allows the state to be abstracted into a monad.

Let’s first look at the initial state. Let’s make a function that given a list of options will make our initial state for us. Since this is a test example we will make the program die if the list is empty (since we know we will pass it a full list).

buildInitialState :: [MenuOption] -> IO MenuState
buildInitialState opts =
let nonEmptyOpts = nonEmpty opts
in case nonEmptyOpts of
Nothing -> die "The list is empty"
Just neo -> return $ MenuState $ makeNonEmptyCursor neo

First we convert our list to a nonEmpty list, and then pattern match against the Maybe to extract our nonEmpty list and make our MenuState containing our cursor. We will use this function to make our initial state in our main function.

Next we want to tell Brick how to draw our app, based on state. This is a fuction that takes our state, and returns a brick widget tree. But first lets define a function that decides how to render a single menu option. The function will take a Bool (whether the item is selected or not) and an option and will return a widget.

selectedAttr = attrName "selected"

drawPath :: Bool -> MenuOption -> Widget n
drawPath False opt = str opt
drawPath True opt = withAttr selectedAttr $ str opt

Here we define an attribute (used later for styling) and then define our function. The function simply says if this item is not selected, return the option in a str widget, and if it is selected, return the option in a str widget wrapped with a selectedAttr attribute.

We can then use this to describe our widget tree:

drawMenu :: MenuState -> [Widget ResourceName]
drawMenu st =
let optionsCursor = view options st
in [ vBox $
concat
[ map (drawPath False) $ reverse $ nonEmptyCursorPrev optionsCursor
, [drawPath True $ nonEmptyCursorCurrent optionsCursor]
, map (drawPath False) $ nonEmptyCursorNext optionsCursor
]
]

First we use a lens to get our options out of our state. Next we return a list containing our widget, a vBox (short for vertical box, effectively a column) wich takes a list of widgets as its argument. For that, we construct a list of widgets from our cursor.

For that we get the items before our selected item from the cursor using nonEmptyCursorPrev and reverse that (as it is stored in reverse order) and map our drawPath function over that, indicating these are not selected. We then create a singleton list with our selected item, transformed into a selected widget. Lastly we fetch the remaining items with nonEmptyCursorNext and again map the drawPath function. We concatenate these three lists and pass that as the argument to the vBox constructor.

Brick now knows how to render our menu given a state.

Now we need to tell Brick how to react to our inputs. This is done with an event system. Vty a graphics library that brick is built on top of, generates events based on our inputs. This is wrapped in a BrickEvent type. We thus need a function that takes a brick event and changes the state accordingly. Sice the state is stored in a MonadState pattern, we use lenses to access and change the state.

handleEvent :: BrickEvent n e -> EventM n MenuState ()
handleEvent e = do
case e of
VtyEvent vtye ->
case vtye of
EvKey KUp [] -> scrollUp -- scroll up defined later
EvKey KDown [] -> scrollDown -- scroll down defined later
EvKey (KChar 'q') [] -> halt
_other -> continueWithoutRedraw
_other -> continueWithoutRedraw

Here we first pattern match against the event, unpacking the button events. If the event is any other type of event we simply ignore it and continue without rerendering our menu (since nothing has changed in the state). We then say if the event is that the Up arrow was pressed, scroll up. If the down arrow was pressed scroll down, and if the q key was typed, stop the system.

To handle the scrolling logic we need to use lenses and MonadState to change what selection the cursor in our state is holding.

scrollUp :: EventM n MenuState ()
scrollUp = do
cursor <- use options
case nonEmptyCursorSelectPrev cursor of
Nothing -> continueWithoutRedraw
Just cursor' -> do
options .= cursor'
return ()

scrollDown :: EventM n MenuState ()
scrollDown = do
cursor <- use options
case nonEmptyCursorSelectNext cursor of
Nothing -> continueWithoutRedraw
Just cursor' -> do
options .= cursor'
return ()

In both of these we first fetch our options cursor from state using the use function, which uses a lens to fetch some data from the MonadState. We then use the nonEmptyCursorSelectPrev and nonEmptyCursorSelectNext functions to change what selection the cursor is pointing to. These functions return a Maybe (NonEmptyCursor a). If Nothing is returned it simply means that the cursor cannot scroll further in that direction. Otherwise a new cursor is returned reflecting the new selection. We then use the .= function, which sets the following value in the MonadState (where the lens is pointing). So in our case we are setting the _options value of our state. We then return () indicating the app should continue and rerender.

And with that we have our inputs reflecting in the state.

Lastly we just need to wire it all together into a Brick App.

app :: App MenuState Event ResourceName
app =
App
{ appDraw = drawMenu -- wire up draw function
, appChooseCursor = showFirstCursor -- tell brick to use first cursor
, appHandleEvent = handleEvent -- wire up event handler
, appStartEvent = return () -- start app with empty event
, appAttrMap = -- wire up 'styling' with attributes
const $
attrMap
defAttr
[ (selectedAttr, black `on` white)
]
}

Here we wire up the rendering function, the event handler function and handle our basic styling.

While the drawMenu function handles the structure of our tree, here we define what the attribute we created earlier should do to the style. Simply we are saying a widget wrapped in our selectedAttr attribute should be rendered with a background of white with a foregroun (text colour) of black.

Then we can build our main function:

main :: IO ()
main = do
let exampleOptions =
[ "first option"
, "second option"
, "option 3"
, "last option"
]

initialState <- buildInitialState exampleOptions
endState <- defaultMain app initialState
return ()

We define our menu items in a list, use our buildInitialState function to build our initial state from that list, then pass that to brick, along with our configured app to start the execution.

We can now run this with

cabal run

And after compilation we should see our menu pop up and allow us to scroll around with the arrow keys.

Example of working menu.

Final Words

This is a very simple use case for Brick, as it has the capacity to build much more complex applications (some amazing examples on their github page), but I hope this simple tutorial has provided a good fundamental understanding of how brick works. Official documentation

Personally this project was fantastic, as Brick perfectly accoplished what I needed, while forcing me to learn about MonadState and lenses (and I cannot look back at not using these), as well as simply giving me some more Haskell practice.

Please feel free to let me know if anything here is wrong, or if I can do or explain something better!

--

--