voidcruiser-test.nl/content/rambles/xmonad-prompts.md

309 lines
11 KiB
Markdown
Raw Permalink Normal View History

---
title: "XMonad Promtps"
date: "2023-03-08T14:20:11+01:00"
author: "$HUMANOID"
tags: ["haskell", "xmonad", "linux"]
2023-03-23 11:31:53 +01:00
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.
2023-03-23 11:31:53 +01:00
This tutorial-of-sorts will assume _some_ Haskell knowledge or not being afraid
2023-03-08 20:20:21 +01:00
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"
```
2023-03-23 11:31:53 +01:00
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.
2023-03-23 11:31:53 +01:00
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
2023-03-24 22:05:38 +01:00
Let's start off by creating a function that can parse our bookmarks file. Here we
2023-03-23 11:31:53 +01:00
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]
```
2023-03-23 11:31:53 +01:00
This function takes a filepath -- the `Filepath` datatype is an alias for
`String` -- and returns `IO [String]`.
2023-03-23 11:31:53 +01:00
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
```
2023-03-24 22:05:38 +01:00
Let's go over what is happening here line by line.
2023-03-23 11:31:53 +01:00
`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.
2023-03-23 11:31:53 +01:00
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.
2023-03-23 11:31:53 +01:00
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.
2023-03-23 11:31:53 +01:00
```haskell
uniqSort . lines $ file
```
And the output of that is piped into `return`:
```haskell
return . uniqSort . lines $ file
```
2023-03-24 22:05:38 +01:00
This function will allow us to parse any given text file. To parse the
Qutebrowser bookmarks file, call it using `.config/qutebrowser/bookmarks/url`
2023-03-23 11:31:53 +01:00
> _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
2023-03-23 11:31:53 +01:00
2023-03-24 22:05:38 +01:00
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.
2023-03-08 20:20:21 +01:00
> ```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`
2023-03-24 22:05:38 +01:00
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.
2023-03-24 22:05:38 +01:00
Let's mostly follow the suggestion in the description of `mkXPrompt` and lets
take a look at:
2023-03-08 20:20:21 +01:00
> ```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
2023-03-08 20:20:21 +01:00
bookmarksFile = ".config/qutebrowser/bookmarks/urls" :: String
```
2023-03-08 20:20:21 +01:00
> I didn't know where to put this, but I created a string to hold the path to my
> bookmarks
2023-03-08 20:20:21 +01:00
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.
2023-03-08 20:20:21 +01:00
```haskell
bookmarkPrompt :: XPConfig -> (String -> X ()) -> X ()
bookmarkPrompt c f = do
bl <- io fileContentList bookmarksFile
mkXPrompt Bookmark c (mkComplFunFromList' c bl) f
```
2023-03-08 20:20:21 +01:00
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
2023-03-08 20:20:21 +01:00
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
2023-03-24 22:05:38 +01:00
with Xorg). Let's go through it line by line.
2023-03-08 20:20:21 +01:00
```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 ...
```