Introduction #

XMonad has it’s own prompt system. Some time ago, I wanted to see if it could replace dmenu entirely. I managed it for the more common usages I had for it. My application launcher, ssh prompt and pass interface were easy to replace using standard XMonad Contrib modules (XMonad.Prompt.Shell, XMonad.Prompt.Ssh and XMonad.Prompt.Pass respectively). However, things became more difficult when it came to my universal/external Qutebrowser bookmarks menu and yt-dlp-and-pipe-viewer wrapper.

This tutorial-of-sorts will assume some Haskell knowledge or not being afraid of diving straight into how Haskell works. I’m not going into great detail on how everything works here.

Bookmarks menu #

The first one I decided to tackle was the bookmarks menu, as it is by far the simplest of the two.

Let’s take a look at the original:

#!/bin/sh
bookmarks="$HOME/.config/qutebrowser/bookmarks/urls"
choice="$(awk '{print$1}' $bookmarks | sort | dmenu -p "Bookmark:" -l 30)"
[ -z $choice ] || qutebrowser "$choice"

Things get interesting at the initialisation of the choice variable:

  1. It takes the contents of Qutebrowser’s bookmarks file
  2. It sorts the results of that
  3. Sends that to dmenu, prompting the user to make a choice

After this, it checks whether choice is empty or not and in case it isn’t, opens Qutebrowser with its contents.

Here is an example of how Qutebrowser saves its bookmarks:

https://www.alpinelinux.org/ index | Alpine Linux
https://www.openbsd.org/ftp.html OpenBSD: Mirrors
https://commonmark.org/ CommonMark
https://xxiivv.com/ Echorridoors
https://100r.co/site/home.html 100R — home
https://solar.lowtechmagazine.com/about.html About this website | LOW←TECH MAGAZINE

Implementation #

Its functionality does boils down to the following:

  1. Parse a given file according to a set of rules, returning it’s contents in the form of a list
  2. Allow the user to make a choice from that list
  3. Launch an application with that choice as parameter

Seems easy enough to implement.

Parsing the Bookmarks file #

Let’s start off by creating a function that can parse our bookmarks file. Here we need something to read a file – in this case a bookmarks file – and return its contents in the form of a list of strings.

fileContentList :: FilePath -> IO [String]

This function takes a filepath – the Filepath datatype is an alias for String – and returns IO [String].

Now for the body of the function:

fileContentList :: FilePath -> IO [String]
fileContentList f = do
    homeDir <- getEnv "HOME"
    file <- readFile (homeDir ++ "/" ++ f)
    return . uniqSort . lines $ file

Let’s go over what is happening here line by line.

fileContentList is a function that takes an argument f; then it starts a do block. do blocks are used to put multiple functions in sequence in the scope of a single function without having them interact with eachother.

Within the do block, it first retrieves the current home directory based on the $HOME environment variable and binds it to homeDir using the getEnv function from the System.Environment module. getEnv returns a string with the contents of the variable given as its argument.

Next, it retrieves the file contents from $HOME/path/to/file using the readFile. This path is created by appending f to the homeDir.

Now for the final line.

First it takes the file and splits it up into a list of strings based on newlines using the lines function.

    lines $ file

Then it pipes the result from that into uniqSort from the XMonad.Prompt module in order to – as the name implies – sort it and get rid of any duplicate items.

    uniqSort . lines $ file

And the output of that is piped into return:

    return . uniqSort . lines $ file

This function will allow us to parse any given text file. To parse the Qutebrowser bookmarks file, call it using .config/qutebrowser/bookmarks/url

Note: I say “pipe” because the ‘.’ function behaves quite similar to pipes in POSIX Shell. However, the correct way of referring to what it does is composition; it takes two functions and passes the output of the first function to the second, thereby creating – or composing a new function. As apposed to how pipes in POSIX Shell work, function composition chains are executed from right to left.

Creating a Prompt #

Let’s see if there is anything in the XMonad.Prompt module that looks like it could help us in creating a prompt.

mkXPrompt :: XPrompt p => p -> XPConfig -> ComplFunction -> (String -> X ()) -> X ()

Creates a prompt given:

  • a prompt type, instance of the XPrompt class.
  • a prompt configuration (def can be used as a starting point)
  • a completion function (mkComplFunFromList can be used to create a completions function given a list of possible completions)
  • an action to be run: the action must take a string and return X ()

This looks like it could serve as the basis for our prompt. The description and type signature tell us that it is going to require an instance of the XPrompt typeclass. So let’s create a Bookmark datatype and implement the showXPrompt function from XPrompt in order to give it a default message when executed and thereby having it derive from XPrompt.

data Bookmark = Bookmark

instance XPrompt Bookmark where
    showXPrompt Bookmark = "Bookmark: "

As its second argument, mkXPrompt requires an instance of XPConfig. The XPConfig typeclass is where you – as the name implies – specify the configuration of XMonad’s prompts. Knowing this we can start to write function that uses mkXPrompt:

bookmarkPrompt c = do
    mkXPrompt Bookmark c

c is our XPConfig argument.

This takes care of the XPrompt p => p -> XPConfig portion of the function.

Now for the completion function, that will handle the list given to our prompt. Let’s mostly follow the suggestion in the description of mkXPrompt and lets take a look at:

mkComplFunFromList' :: XPConfig -> [String] -> String -> IO [String]

This function takes a list of possible completions and returns a completions function to be used with mkXPrompt. If the string is null it will return all completions.

This is how Qutebrowser and dmenu act by default with a given list of possible options.

bookmarksFile = ".config/qutebrowser/bookmarks/urls" :: String

I didn’t know where to put this, but I created a string to hold the path to my bookmarks

So it takes an instance of XPConfig – that will again be our c argument, and a list of strings. Here is where we feed it the contents of our file using our fileContentList function. We will do this by binding the output to, say bl for “bookmark list” with <-. Since fileContentList is a member of the IO monad and we’re working in, we have to call it using the io function, which is an alias for the liftIO function.

bookmarkPrompt :: XPConfig -> (String -> X ()) -> X ()
bookmarkPrompt c f = do
    bl <- io fileContentList bookmarksFile
    mkXPrompt Bookmark c (mkComplFunFromList' c bl) f

You’ll see that I’ve also added argument f, this is the function we’re going to use to actually do something with our prompt output. Considering we’re working with bookmarks, opening them in a browser would make sense.

openBookmark :: String -> X ()
openBookmark bookmark = do
    browser <- io getBrowser
    spawn $ browser ++ " '" ++ getUrl bookmark ++ "'"
        where getUrl = head . words

openBookmark is a function that takes a string and returns something in the context of the X monad (hence the name “XMonad”, it’s a monad that interacts with Xorg). Let’s go through it line by line.

    browser <- io getBrowser

First we get user’s browser using the getBrowser function from the XMonad.Prompt.Shell module and bind that to browser.

This function checks the $BROWSER environment variable and if it isn’t set, it defaults to “firefox”.

    spawn $ browser ++ " '" ++ getUrl bookmark ++ "'"

Since getBrowser returns a string, we can append things to it and feed that to spawn. In this case, we get the URL portion of the bookmark entry surrounded by single quotes in case a given bookmark contains any symbols that mess up our shell. After all, what spawn ultimately does is feed a given string to /bin/sh as a command to execute.

        where getUrl = head . words

For get getUrl, we take the given string, split it into a list of strings based on space characters, pipe that into head, thus retrieving the first item.

Keybinding #

We now have a set of functions that create a prompt populated with our Qutebrowser bookmarks file (any other list of URLs will also work) which will open our browser when choosing one.

Now all we have to do is bind it to a key. Personally I use the XMonad.Util.EZConfig so I have the following in my keybindings:

, ("M-M1-C-b", bookmarkPrompt (myXPConfig {autoComplete = Just 200000}) openBookmark Bookmark)

If you use the default way of defining keybindings you can use something like the following:

, ((modm .|. controlMask, xK_b), bookmarkPrompt def openBookmark)

def is a reference to the default implementation of XPConfig.

Everything Together #

Everything put together, your config should have something like the following added.

data Bookmark = Bookmark

instance XPrompt Bookmark where
    showXPrompt Bookmark = "Bookmark: "

bookmarksFile = ".config/qutebrowser/bookmarks/urls" :: String

fileContentList :: FilePath -> IO [String]
fileContentList f = do
    homeDir <- getEnv "HOME"
    file <- readFile (homeDir ++ "/" ++ f)
    return . uniqSort . lines $ file

bookmarkPrompt :: XPConfig -> (String -> X ()) -> X ()
bookmarkPrompt c f = do
    bl <- io fileContentList bookmarksFile
    mkXPrompt Bookmark c (mkComplFunFromList' c bl) f

openBookmark :: String -> X ()
openBookmark bookmark = do
    browser <- io getBrowser
    spawn $ browser ++ " '" ++ getUrl bookmark ++ "'"
        where getUrl = head . words

-- ... keybindings
, ((modm .|. controlMask, xK_b), bookmarkPrompt def openBookmark)
-- more keybindings ...