309 lines
11 KiB
Markdown
309 lines
11 KiB
Markdown
---
|
||
title: "XMonad Promtps"
|
||
date: "2023-03-08T14:20:11+01:00"
|
||
author: "$HUMANOID"
|
||
tags: ["haskell", "xmonad", "linux"]
|
||
description: "There aren't a lot of instructions or explanations on creating XMonad prompts, or at least not within a minute of checking my searx instance. This is my attempt at filling that gap."
|
||
toc: true
|
||
---
|
||
# 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:
|
||
|
||
```sh
|
||
#!/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.
|
||
|
||
```haskell
|
||
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:
|
||
|
||
```haskell
|
||
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.
|
||
|
||
```haskell
|
||
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.
|
||
|
||
|
||
```haskell
|
||
uniqSort . lines $ file
|
||
```
|
||
|
||
And the output of that is piped into `return`:
|
||
```haskell
|
||
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`](https://hackage.haskell.org/package/xmonad-contrib-0.17.1/docs/XMonad-Prompt.html)
|
||
module that looks like it could help us in creating a prompt.
|
||
|
||
|
||
> ```haskell
|
||
> 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`.
|
||
|
||
```haskell
|
||
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`:
|
||
|
||
```haskell
|
||
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:
|
||
|
||
> ```haskell
|
||
> 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.
|
||
|
||
```haskell
|
||
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.
|
||
|
||
```haskell
|
||
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.
|
||
|
||
```haskell
|
||
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.
|
||
|
||
```haskell
|
||
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".
|
||
|
||
```haskell
|
||
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.
|
||
|
||
```haskell
|
||
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:
|
||
|
||
```haskell
|
||
, ("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:
|
||
|
||
```haskell
|
||
, ((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.
|
||
|
||
```haskell
|
||
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 ...
|
||
```
|