XMonad, Spacemacs Style
Background
I’ve been using XMonad for almost 10 years but I barely know my own key bindings. Every time I open xmonad.hs
I find bindings I don’t remember setting, doing things I’d forgotten were even possible. I’m hesitant to add new bindings because I know I’ll forget them.
As a recent convert to Spacemacs, one of the things I love most is that the key bindings are mnemonic key sequences, with help that pops up when you hesitate along the way.
I want the same thing in XMonad.
Making It So
Key Sequences
The first problem was figuring out how to bind key sequences instead of key presses. I searched for things like “xmonad key sequence,” “xmonad multiple key bind,” “xmonad prefix key,” and so on, until finally a blog post by Alexander Kojevnikov pointed me in the right direction.
XMonad.Actions.Submap lets you bind a key to the submap
action, which accepts another map of key bindings. When called, submap
intercepts the next keypress and dispatches accordingly.
Even Better: Emacs-style Bindings
From Submap I stumbled onto XMonad.Util.EZConfig, which makes the key binding syntax much nicer. Instead of specifying a tuple with the modifier bitmask and appropriate xK_
variable, you just use a string with an Emacs-like notation. It even supports specifying a sequence of keys, so you don’t need to use Submap directly.
-- Using only Submap
((modm, xK_x), submap . M.fromList $ [((0, xK_w), spawn "xmessage 'woohoo!'")])
-- With EZConfig
("M-x w", spawn "xmessage 'woohoo!'")
Visual Feedback
Submap and EZConfig don’t provide any kind of feedback that you’ve entered a submap, or expose any way to add your own. We can use >>
to call spawn
before submap
, but submap
blocks waiting for input; dmenu
won’t run because it can’t grab the keyboard, and xmessage
won’t appear because XMonad won’t notice the new window until after submap
exits.
Luckily, dzen2
works and is perfect for what I have in mind. We just have to go back to calling submap
manually:
mySubmap = submap . M.fromList $
[((0, xK_w), spawn "xmessage 'woohoo!'")
]
main = xmonad $ defaultConfig `additionalKeys`
[ ("M-m", spawn "echo Entered Submap | dzen2 -p 2"
>> mySubmap) ]
With no parameters, dzen2
closes when its input is closed. We can use -p 2
to keep it open for 2 seconds, but what we really want is for it to wait a second before appearing, and to disappear when we exit the submap. With spawnPipe
from XMonad.Util.Run we can get a handle that we can write to and close with System.IO
:
mySubmap = submap . M.fromList $
[((0, xK_w), spawn "xmessage 'woohoo!'")
]
dzen message action = do
handle <- spawnPipe "sleep 1 && dzen2"
io $ hPutStrLn handle message
action
io $ hClose handle
main = xmonad $ defaultConfig `additionalKeys`
[ ("M-m", dzen "In Submap" mySubmap]) ]
(io
is necessary to lift the IO monad into the X monad, because Haskell)
Parsing the Keymap
Now we can pop up a message, how do we make it tell us what the keys do?
XMonad.Util.NamedActions is an option, but it looks really cumbersome. I don’t want to have to think of documentation and keep it up to date. It might be possible to do something with Template Haskell to auto-generate names for actions, but that seems like too much work.
Since I keep my bindings neatly formatted anyway, the easiest thing to do is just yank the keys and actions directly out of xmonad.hs.
I know my submaps are always going to start with a line like submapName =
and end with a ]
on a line by itself. awk
makes it easy to grab a range of lines:
~$ cat ~/.xmonad/xmonad.hs | awk '/^mySubmap/,/]$/'
mySubmap = submap . M.fromList $
[((0, xK_w), spawn "xmessage 'woohoo!'")
]
With a little regular expression magic, sed
can extract the import parts:
~$ !! | sed -r '1d;$d;s/.*xK_(.)\), (.*)\)$/\1 -> \2/'
w -> spawn "xmessage 'woohoo!'"
(-r
swaps which kind of paren we need to quote, and 1d;$d
trims the first and last line)
With a bit of work you could do this with either sed
or awk
alone, but I think this way is simpler.
Later, once we have a bunch of them, column
can wrap them into columns:
~$ for i in {1..6}; do !!; done | column
w -> spawn "xmessage 'woohoo!'" w -> spawn "xmessage 'woohoo!'"
w -> spawn "xmessage 'woohoo!'" w -> spawn "xmessage 'woohoo!'"
w -> spawn "xmessage 'woohoo!'" w -> spawn "xmessage 'woohoo!'"
Now instead of calling dzen2
directly, we can call a shell script that contains this command pipeline:
#!/bin/sh
KEYMAP=$1
( echo "$KEYMAP" \
; cat ~/.xmonad/xmonad.hs \
| awk '/^'"$KEYMAP"'/,/]$/' \
| sed -r '1d;$d;s/.*xK_(.)\), (.*)\)$/\1 -> \2/' \
| columns | expand
; cat ) | dzen2 -l 5 -e onstart=uncollapse
A few things to note:
columns
outputs tab characters, which don’t work in dzen2.expand
converts them to spaces.- We need to run the pipeline in a subshell that ends with
; cat
to connectdzen2
’s input channel to the output handle we have in XMonad. - To make dzen2 show multiple lines, we need the
-l
parameter. This makes the first line the title, and the rest show up in an additional window that is hidden by default unless we add-e onstart=uncollapse
.
Positioning
The last piece of the puzzle is getting the help to popup on the bottom of whatever monitor has focus, and dzen2
doesn’t seem to have any easy way to do this. It does make it easy to manually set the location and dimensions, we just need to figure out what those are.
We can get the location and dimensions of the focused screen in XMonad by stealing some code from floatLocation
in XMonad.Operations:
windowScreenSize :: Window -> X (Rectangle)
windowScreenSize w = withDisplay $ \d -> do
ws <- gets windowset
wa <- io $ getWindowAttributes d w
bw <- fi <$> asks (borderWidth . config)
sc <- fromMaybe (W.current ws) <$> pointScreen (fi $ wa_x wa) (fi $ wa_y wa)
return $ screenRect . W.screenDetail $ sc
where fi x = fromIntegral x
focusedScreenSize :: X (Rectangle)
focusedScreenSize = withWindowSet $ windowScreenSize . fromJust . W.peek
We can then pass those to our script with something like:
keyMapDoc :: String -> X Handle
keyMapDoc name = do
ss <- focusedScreenSize
handle <- spawnPipe $ unwords ["~/.xmonad/showHintForKeymap.sh", name, show (rect_x ss), show (rect_y ss), show (rect_width ss), show (rect_height ss)]
return handle
The math is straightforward so I won’t cover it here. We end up with something like:
LINE_HEIGHT=14
INFO=$(cat ...)
N_LINES=$(wc -l <<< "$INFO")
OFFSET=$((LINE_HEIGHT * N_LINES))
Y=$((BOTTOM - OFFSET))
(echo "$KEYMAP"; echo "$INFO"; cat) | dzen2 -h $LINE_HEIGHT -y $Y ...
The Result
It’s not quite nice enough to start a project called spacenads
yet, but it’s exactly what I wanted. I have made some more tweaks and gone a little further than is described here, but you can see what I’m actually using on my github.