config.phundrak.com/docs/stumpwm.org
Lucien Cartier-Tilet 3354f79554
All checks were successful
deploy / build (push) Successful in 3m41s
docs: typos
2023-12-10 15:09:07 +01:00

72 KiB
Raw Blame History

StumpWM config

/phundrak/config.phundrak.com/media/commit/760423c03b30c8bdb5b60bcdd9283464e48355f9/docs/img/stumpwm.png

StumpWM

Introduction

What is StumpWM?

StumpWM is a tiling window manager inheriting from RatPoison, written entirely in Common Lisp and compiled with SBCL. While it is not a dynamic tiling window manager like Awesome is, its ability of managing windows in frames and using keychords with keymaps like Emacs does is a huge plus for me, not to mention the fact its configuration file is written in Common Lisp, a general programming language, a bit like Awesome. This makes it an i3 on steroids, sort of. It also uses a lot of Emacs concepts, which is great for an Emacs user such as myself.

Why not EXWM then?

Sometimes, some actions within Emacs are blocking actions, making the computer not usable while the command runs. It also does not play nice with video games (pun intended), which is also a negative point for me. And I also find EXWM more confusing overall than StumpWM.

What this file is for

This file has two main goals:

  • This will be the actual source code of my StumpWM configuration, thanks to Emacs org-mode, and thanks to org-modes literate config capabilities. Almost all the visible source blocks if not all will be included in my configuration files through tangling, which can be done in Emacs when this file is opened through M-x org-babel-tangle, which will write my configuration files based on the source blocks present in this document. This file is not only my configs documentation, it is my configuration.
  • Be my documentation on my StumpWM configuration. That way, Ill never forget which block of code does what. And maybe, hopefully, someone could learn a thing or two if they want to get into StumpWM but dont know where to begin. You should be able to read this document as a book, with each chapter dedicated to a different aspect of StumpWM.

Organization of my files

While I could make this file write everything to the same file (the actual source will be in a single file after all), I find it easier to debug StumpWM if everythings split up. For now, my configuration follows this architecture:

init.el
My main configuration file, glues everything together. It loads all of my configuration files as well as some modules I find useful;
colors.lisp
This file defines colours that will be used in my theme.lisp and modeline.lisp files. Lets make my code DRY, or as I prefer to say, DRYD (Dont Repeat Yourself Dummy).
commands.lisp
Lisp commands, in case I want to bind some complicated actions to a keybind that is not just a simple shell command;
keybindings.lisp
My list of keymaps and keybinds which make StumpWM actually usable;
modeline.lisp
This defines the modeline, a concept taken from Emacs which can display various information such as a list of workspaces, including the current one;
placement.lisp
This file manages my workspaces and the default placement of various windows;
utilities.lisp
Here you can find my StumpWM configuration that isnt really related to the rest of the config, for instance utility code for connecting by SSH to some host.
theme.lisp
manages the colour theme of StumpWM, the default placement of some windows and StumpWMs gaps.

You will also find below my xinit file for StumpWM, exported to $HOME/.xinitrc.stumpwm, which I use to start Stump through startx ~/.xinitrc.stumpwm.

# this makes it work in Ubuntu
xhost +SI:localuser:$USER

# Set fallback pointer
xsetroot -cursor_name left_ptr

# Fix scrolling on some GTK3 applications
export GDK_CORE_DEVICE_EVENTS=1

# in case Java applications display /nothing/
# wmname LG3D
# export _JAVA_AWT_WM_NONREPARENTING=1

autorandr -l home

exec stumpwm

Init file

As mentioned in the documentation, the configuration files can be in different locations, but I chose an Emacs-like configuration: put everything in ~/.stumpwm.d/. We begin by indicating quicklisp how to properly initialize:

#-quicklisp
(let ((quicklisp-init (merge-pathnames "quicklisp/setup.lisp"
                                       (user-homedir-pathname))))
  (when (probe-file quicklisp-init)
    (load quicklisp-init)))

Then, our first StumpWM-related code is declaring we are using the stumpwm package, and this is also our default package. This will allow us to avoid using the prefix stumpwm: each time we are using a function or a variable from this package.

(in-package :stumpwm)
(setf *default-package* :stumpwm)

Since I install StumpWM with my package manager (I use the AURs stumpwm-git package), StumpWMs modules are installed to /usr/share/stupmwm/contrib/utils/, lets indicate that to StumpWM.

(set-module-dir "/usr/share/stupmwm/contrib/")

A startup message can be used when initializing StumpWM. For now, lets set it to nil.

(setf *startup-message* nil)

The first thing I want to do after that is to set some decent cursor pointer as well as get a bunch of stuff started. To see whats in the autostart script, see here.

(run-shell-command "autostart")

Next I need to register the AltGr key so it works correctly when used. On my system, the value of *altgr-offset* is 4, but on yours it might be 6, so be careful and refer to the manual on that matter.

(setf *altgr-offset* 4)
(register-altgr-as-modifier)

Now, well load a couple of my custom files that will be described below:

File to be loaded
bluetooth.lisp
commands.lisp
placement.lisp
keybindings.lisp
theme.lisp
utilities.lisp
modeline.lisp
systemd.lisp
(mapconcat (lambda (file)
             (format "(load \"~/.stumpwm.d/%s\")" (car file)))
           files
           "\n")

This is equivalent to the Common Lisp code:

(load "~/.stumpwm.d/bluetooth.lisp")
(load "~/.stumpwm.d/commands.lisp")
(load "~/.stumpwm.d/placement.lisp")
(load "~/.stumpwm.d/keybindings.lisp")
(load "~/.stumpwm.d/theme.lisp")
(load "~/.stumpwm.d/utilities.lisp")
(load "~/.stumpwm.d/modeline.lisp")
(load "~/.stumpwm.d/systemd.lisp")

Once the modeline file is loaded, lets indicate StumpWM to activate it:

(when *initializing*
  (mode-line))

Another thing I want to set is to use the super key to move floating windows and window focus to transfer from one window to another only on click.

(setf *mouse-focus-policy*    :click
      ,*float-window-modifier* :SUPER)

Next, some modules will be loaded from the stumpwm-contrib package (which is included in stumpwm-git in the AUR). Here is a short list including a short description of what they are for:

Module Name Why It Is Loaded
beckon Bring the mouse cursor to the current window
end-session Gracefully end programs when ending user session
globalwindows Navigate between windows from all workspaces
mpd Interact with MPD
stump-backlight Native management of backlight in StumpWM
urgentwindows Get urgent windows
(mapconcat (lambda (module)
             (format "(load-module \"%s\")" (car module)))
           modules
           "\n")
(load-module "beckon")
(load-module "end-session")
(load-module "globalwindows")
(load-module "mpd")
(load-module "stump-backlight")
(load-module "urgentwindows")

In order to be able to use MPD from StumpWM itself, well need to connect to it.

(mpd:mpd-connect)

Finally, we can notify the user everything is ready.

(setf *startup-message* "StumpWM is ready!")

And its done! We can now move on to the creation of the other CLisp files.

Colours

If youve had a look at the rest of my dotfiles, you may have noticed I really like the Nord theme. No wonder we can find it here again! Here is a small table listing the Nord colours:

Name Value
nord0 #2e3440
nord1 #3b4252
nord2 #434c5e
nord3 #4c566a
nord4 #d8dee9
nord5 #e5e9f0
nord6 #eceff4
nord7 #8fbcbb
nord8 #88c0d0
nord9 #81a1c1
nord10 #5e81ac
nord11 #bf616a
nord12 #d08770
nord13 #ebcb8b
nord14 #a3be8c
nord15 #b48ead

Ill prefix the variables name with phundrak- just in case it might conflict with another package I might use in the future, so the CLisp code looks like so:

(mapconcat (lambda (color)
             (format "(defvar phundrak-%s \"%s\")" (car color) (cadr color)))
           colors
           "\n")
(defvar phundrak-nord0 "#2e3440")
(defvar phundrak-nord1 "#3b4252")
(defvar phundrak-nord2 "#434c5e")
(defvar phundrak-nord3 "#4c566a")
(defvar phundrak-nord4 "#d8dee9")
(defvar phundrak-nord5 "#e5e9f0")
(defvar phundrak-nord6 "#eceff4")
(defvar phundrak-nord7 "#8fbcbb")
(defvar phundrak-nord8 "#88c0d0")
(defvar phundrak-nord9 "#81a1c1")
(defvar phundrak-nord10 "#5e81ac")
(defvar phundrak-nord11 "#bf616a")
(defvar phundrak-nord12 "#d08770")
(defvar phundrak-nord13 "#ebcb8b")
(defvar phundrak-nord14 "#a3be8c")
(defvar phundrak-nord15 "#b48ead")

Finally, lets also modify the default colors StumpWM has. Ill try to respect the original colours while converting them to Nord. We also need to reload them now that we modified them.

(setq *colors*
      `(,phundrak-nord1   ;; 0 black
        ,phundrak-nord11  ;; 1 red
        ,phundrak-nord14  ;; 2 green
        ,phundrak-nord13  ;; 3 yellow
        ,phundrak-nord10  ;; 4 blue
        ,phundrak-nord14  ;; 5 magenta
        ,phundrak-nord8   ;; 6 cyan
        ,phundrak-nord5)) ;; 7 white

(when *initializing*
  (update-color-map (current-screen)))

And with that were done!

Mode-Line

The timeout of the modeline indicates how often it refreshes in seconds. I think two seconds is good.

(setf *mode-line-timeout* 2)

Formatting Options

Next we get to the content of the modeline. This format follows the format indicated in the manpage of date.

(setf *time-modeline-string* "%F %H:%M")

Lets also indicate how the groupname is displayed.

(setf *group-format* "%t")

The window format should display first its window number, then its titled, limited to 30 characters.

(setf *window-format* "%n: %30t")

Mode-Line Theme

The modeline is pretty easy. First, lets load the colors.lisp file we just created:

(load "~/.stumpwm.d/colors.lisp")

Next, we can set some colours for the modeline. Lets set the background of the modeline to Nord1 and the foreground to Nord5, I think this is a pretty good combination.

(setf *mode-line-background-color* phundrak-nord1
      ,*mode-line-foreground-color* phundrak-nord5)

We could also use some borders in the modeline. But we wont. Lets still set its colour to Nord1, just in case.

(setf *mode-line-border-color* phundrak-nord1
      ,*mode-line-border-width* 0)

Mode-Line Modules

Here are some modules that we will load for the modeline:

Module Name Why Do I Need It?
battery-portable Get information on the battery level of a laptop
cpu Get the CPU usage
mpd Display MPDs status
mem Get the memory usage
(mapconcat (lambda (module)
             (format "(load-module \"%s\")" (car module)))
           modules
           "\n")
(load-module "battery-portable")
(load-module "cpu")
(load-module "mpd")
(load-module "mem")

We need to set some variables, so modules can be displayed correctly. Note that the character between the font switchers in the second CPU formatter is U+E082, which symbolizes the CPU.

(setf cpu::*cpu-modeline-fmt*        "%c"
      cpu::*cpu-usage-modeline-fmt*  "^f2^f0^[~A~2D%^]"
      mem::*mem-modeline-fmt*        "%a%p"
      mpd:*mpd-modeline-fmt*         "%a - %t"
      mpd:*mpd-status-fmt*           "%a - %t"
      ,*hidden-window-color*          "^**"
      ,*mode-line-highlight-template* "«~A»")

Generating the Mode-Line

We can indicate what to display in our modeline. Each formatter will be separated by a Powerline separator with the code point 0xE0B0 in the font I am using (see Fonts).

Formatter What it does Command?
%g Display list of groups
%W Display list of windows in the current group and head
^> Rest of the modeline align to the right
docker-running Display number of docker containers currently running yes
mu-unread Display number of unread emails yes
%m Display current MPD song
%C Display CPU usage
%M Display RAM usage
%B Display battery status
%d Display date
(("%g") ("%W") ("^>") ("docker-running" . t) ("mu-unread" . t) ("%m") ("%C") ("%M") ("%B") ("%d"))
(defvar *mode-line-formatter-list*
  '<<modeline-format-gen()>>
  "List of formatters for the modeline.")

As you can see, generate-modeline generates the string defining *screen-mode-line-format* from the list of formatters we gave it with the table /phundrak/config.phundrak.com/src/commit/760423c03b30c8bdb5b60bcdd9283464e48355f9/docs/modeline-format.

(defun generate-modeline (elements &optional not-invertedp rightp)
  "Generate a modeline for StumpWM.
ELEMENTS should be a list of `cons'es which `car' is the modeline
formatter or the shell command to run, and their `cdr' is either nil
when the `car' is a formatter and t when it is a shell command."
  (when elements
    (cons (format nil
                  " ^[~A^]^(:bg \"~A\") "
                  (format nil "^(:fg \"~A\")^(:bg \"~A\")^f1~A^f0"
                          (if (xor not-invertedp rightp) phundrak-nord1 phundrak-nord3)
                          (if (xor not-invertedp rightp) phundrak-nord3 phundrak-nord1)
                          (if rightp "" ""))
                  (if not-invertedp phundrak-nord3 phundrak-nord1))
          (let* ((current-element (car elements))
                 (formatter       (car current-element))
                 (commandp        (cdr current-element)))
            (cons (if commandp
                      `(:eval (run-shell-command ,formatter t))
                    (format nil "~A" formatter))
                  (generate-modeline (cdr elements)
                                     (not not-invertedp)
                                     (if (string= "^>" (caar elements)) t rightp)))))))

It is then easy to define a command that can call this function and set this variable, so we can sort of reload the mode-line.

(defcommand reload-modeline () ()
  "Reload modeline."
  (sb-thread:make-thread
   (lambda ()
     (setf *screen-mode-line-format*
           (cdr (generate-modeline *mode-line-formatter-list*))))))

And actually, lets reload the modeline immediately.

(reload-modeline)

Groups and placement

Ive been used to ten groups, or workspaces, or tags, since I began using tiling window managers. I shall then continue this habit. Here is the list of groups I will be using:

Groups Number Windows Type
[EMACS] 1 Tiling
[TERM] 2 Tiling
[WWW] 3 Tiling
[PRIV] 4 Tiling
[FILES] 5 Tiling
(let ((make-group (lambda (group &optional first-p)
                    (let ((group-name (car group))
                          (group-type (nth 3 group)))
                      (format "%S" `(,(if first-p 'grename
                                        (pcase group-type
                                          ("Dynamic" 'gnewbg-dynamic)
                                          ("Floating" 'gnewbg-float)
                                          (otherwise 'gnewbg)))
                                     ,group-name))))))
  (string-join `(,(funcall make-group (car groups) t)
                 ,@(mapcar (lambda (group)
                             (funcall make-group group))
                           (cdr groups)))
               "\n"))
(grename "[EMACS]")
(gnewbg "[TERM]")
(gnewbg "[WWW]")
(gnewbg "[PRIV]")
(gnewbg "[FILES]")

Groups are specified this way:

(when *initializing*
  <<gen-groups()>>)

By default, if nothing is specified as per the group type, my groups are manual tiling groups. Otherwise, as you can see above, they can also be dynamic tiling groups or floating groups.

Next, lets make sure no previous window placement rule is in place, this will avoid unexpected and hard-to-debug behaviour.

(clear-window-placement-rules)
(require 'seq)
(let ((output "")
      (rules (seq-filter (lambda (rule) rule)
                         (mapcar (lambda (line)
                                   (let ((classes (caddr line)))
                                     (unless (string= "" classes)
                                       (cons
                                        (split-string classes "," t "[[:space:]]*")
                                        (car line)))))
                                 rules))))
  (progn
    (seq-do (lambda (rule)
              (let ((classes (car rule))
                    (group   (cdr rule)))
                (dolist (class classes)
                  (setf output (format "%s\n%s"
                                       `(define-frame-preference ,(format "\"%s\"" group)
                                          (nil t t :class ,(format "\"%s\"" class)))
                                       output)))))
            rules)
    output))

Dynamic groups, if any is created, should have a split ratio of half of the available space.

(setf *dynamic-group-master-split-ratio* 1/2)

Theme

As in the modeline file, the first thing well do is to load our colours.

(load "~/.stumpwm.d/colors.lisp")

We can now go onto more serious business.

Fonts

This gave me quite the headache when I tried to set this up: in order to use TTF fonts (note: it is not possible to use OTF fonts, see below), we need to use the ttf-fonts module which relies on the clx-truetype library. A few years back, it should have been possible to get it installed with a call to src_lisp[:exports code]{(ql:quickload :clx-truetype)}, but it is no longer available! Theres a quickfix available while we wait for clx-truetype to be once again available: clone it in quicklisps local projects. You will obviously need to have quicklisp installed (for that, follow the official instructions), then execute the following shell commands:

cd ~/quicklisp/local-projects/
git clone https://github.com/lihebi/clx-truetype.git

This will make clx-truetype available to quicklisp, and you can run again

(ql:quickload :clx-truetype)
without an issue (running it again is necessary to install its dependencies).

In order for it to work, install quicklisp and dont forget to run

(ql:add-to-init-file)
so it is loaded each time you start your Lisp interpreter. SBCL should be your CommonLisp interpreter of choice since StumpWM is generally compiled with it. The main advantage is also that SBCL supports multithreading, unlike CLisp. In case StumpWM doesnt find your font, spin up SBCL and execute the following lines:

(ql:quickload :clx-truetype)
(xft:cache-fonts)

If you want a list of font families available, you can execute the following:

(clx-truetype:get-font-families)

If you want to know the subfamilies of a certain family, you can execute this:

(clx-truetype:get-font-subfamilies "Family Name")

Now that this is out of the way, lets add two lines so we can use TTF fonts:

(ql:quickload :clx-truetype)
(load-module "ttf-fonts")

The documentation says we should be able to also use OTF fonts, but so far Ive had no luck loading one.

Loading more than one font to use some fallback fonts also doesnt seem to work, unlike specified in the documentation (I wanted to use a CJK font, but it doesnt appear to work), we need to manually change the font used which isnt very user-friendly, especially if you might have CJK characters appear in otherwise regular text.

Something that didnt click immediately for me (and I think StumpWMs documentation on this could be improved) is that set-font can be used to set either one main font for StumpWM, as one might guess reading the documentation — or you can set a list of them! And this is great, since my main font does not support some characters I regularly have in my windows title, such as CJK characters! However, be aware the second font and further arent fallback fonts. They are additional fonts you can switch to manually through the use of ^f<n> (<n> being the desireds font index in the 0-indexed font list). But if a font cannot render a character, it will simply display an empty rectangle instead of falling back to another font. Thats annoying… Here is my list of fonts I want loaded:

Family Subfamily Size
Unifont-JP Regular 10
DejaVu Sans Mono for Powerline Book 8.5
siji Medium 10
FantasqueSansMono Nerd Font Mono Regular 9.5
(format "(set-font `(%s))"
        (mapconcat (lambda (font)
                    (let ((family    (nth 0 font))
                          (subfamily (nth 1 font))
                          (size      (nth 2 font)))
                      (format ",%s" `(make-instance 'xft:font
                                                    :family ,(format "\"%s\"" family)
                                                    :subfamily ,(format "\"%s\"" subfamily)
                                                    :size ,size
                                                    :antialias t))))
                  fonts
                  "\n            "))

The code equivalent of this table can be seen below:

(set-font `(,(make-instance 'xft:font :family "Unifont-JP" :subfamily "Regular" :size 10 :antialias t)
            ,(make-instance 'xft:font :family "DejaVu Sans Mono for Powerline" :subfamily "Book" :size 8.5 :antialias t)
            ,(make-instance 'xft:font :family "siji" :subfamily "Medium" :size 10 :antialias t)
            ,(make-instance 'xft:font :family "FantasqueSansMono Nerd Font Mono" :subfamily "Regular" :size 9.5 :antialias t)))

As far as I know, Unifont is the only font Ive tested that displays monospaced Japanese characters in StumpWM. I tried DejaVu, IBM Plex, and a couple of others but only this one works correctly. DejaVu is here for the Powerline separator. If you know of another monospaced font that displays Japanese characters, or even better CJK characters, please tell me! My email address is at the bottom of this webpage.

Colors

We can now set a couple of colors for StumpWM. Not that we will see them often since I dont like borders on my windows, but in case I want to get them back, theyll be nice to have.

(set-border-color        phundrak-nord1)
(set-focus-color         phundrak-nord1)
(set-unfocus-color       phundrak-nord3)
(set-float-focus-color   phundrak-nord1)
(set-float-unfocus-color phundrak-nord3)

Lets also set the colours of the message and input windows:

(set-fg-color phundrak-nord4)
(set-bg-color phundrak-nord1)

As I said, I dont like borders, so Ill remove them. Ill still keep the windows title bar available when its floating, and this is also where I can set the format of its title: its number as well as its name, limited to thirty characters.

(setf *normal-border-width*       0
      ,*float-window-border*       0
      ,*float-window-title-height* 15
      ,*window-border-style*       :none
      ,*window-format*             "%n:%t")

I also have a StumpWM fork that introduces two new variables for customizing which-key keybindings. I submitted a pull request, so it might come one day to StumpWM.

(setf *key-seq-color* "^2")
(setf *which-key-format* (concat *key-seq-color* "*~5a^n ~a"))

Message and Input Windows

The Input windows as well as the message windows should both be at the top of my screen. And I believe a padding of five pixels for the message windows is good.

(setf *input-window-gravity*     :top
      ,*message-window-padding*   10
      ,*message-window-y-padding* 10
      ,*message-window-gravity*   :top)

Gaps Between Frames

I love gaps. When I was using i3, I used the i3-gaps package, not just plain i3. In Awesome, I still have gaps. And in StumpWM, I shall still use gaps. In order to use them, lets load a module dedicated to gaps in StumpWM:

(load-module "swm-gaps")

Now that this is done, I can now set some variables bound to this package.

(setf swm-gaps:*head-gaps-size*  0
      swm-gaps:*inner-gaps-size* 5
      swm-gaps:*outer-gaps-size* 40)

Finally, lets enable our gaps:

(when *initializing*
  (swm-gaps:toggle-gaps))

Commands

The first command I declare in this file is a command that will avoid me invoking too many Firefox instances. Either Firefox is not already running and an instance is launched, or one already is, and we are brought to it. This is done like so:

(defcommand firefox () ()
  "Run or raise Firefox."
  (sb-thread:make-thread (lambda () (run-or-raise "firefox" '(:class "Firefox") t nil))))

Next, this command will not only close the current window, but it will also close the current frame.

(defcommand delete-window-and-frame () ()
  "Delete the current frame with its window."
  (delete-window)
  (remove-split))

The two following commands will create a new frame to the right and below the current frame respectively, then focus it.

(defcommand hsplit-and-focus () ()
  "Create a new frame on the right and focus it."
  (hsplit)
  (move-focus :right))

(defcommand vsplit-and-focus () ()
  "Create a new frame below and move focus to it."
  (vsplit)
  (move-focus :down))

Now, lets create a command for invoking the terminal, optionally with a program.

(defcommand term (&optional program) ()
  "Invoke a terminal, possibly with a @arg{program}."
  (sb-thread:make-thread
   (lambda ()
     (run-shell-command (if program
                            (format nil "kitty ~A" program)
                            "kitty")))))

Keybinds

Buckle up, this chapter is going to be long, because me loves LOTS of keybinds.

First, lets declare again we are using the default package stumpwm:

(in-package :stumpwm)

This will avoid us always repeating stumpwm:define-key or stumpwm:kbd instead of simply define-key and kbd.

StumpWM behaves a bit like Emacs in terms of keybinds. You have keymaps, which are a collection of keybinds, which in turn call CLisp functions. However, unlike Emacs, you have to declare a lot of keymaps, because StumpWM cannot (yet) understand keybinds such as

(kbd "C-x c l")
, so you end up creating a keybind to a keymap which contains other keybinds, which might contain a couple of keybinds to other keymaps. I hope this will get improved soon.

There are also two keymaps you need to be aware of:

*top-map*
This is the keymap available literally everywhere. With this keymap, you can emulate most of your keybinds you have in other window managers. For instance, I cannot live without s-RET for creating new shells, so Ill bind it to *top-map*. But its good practice to avoid polluting *top-map* with too many keybinds.
*root-map*
This keymap is the default keymap that is already somewhat populated. It is available after hitting the prefix key set with set-prefix-key which we will see just below.

It is interesting to note that once you entered any keymap, except *top-map*, if you hit ? you will see the list of available keybinds. Id like it if something similar to general in Emacs too could be implemented: give any arbitrary name to the keybind you just declared which would be displayed instead of the actual function or keymap called by keybind. It would be nicer to see frames rather than *my-frames-management-keymap*.

Anyway, as mentioned above, *root-map* is already pre-populated with some cool stuff for you, and you can access it with a prefix which is by default C-t. But if this doesnt suit you, you can always redefine it with set-prefix-key. I personally like to have my space key as a leader key, but in order to not have it conflict with Emacs, I also need to press the super key too.

(set-prefix-key (my/kbd "s-SPC"))

Also, lets enable which-key:

(which-key-mode)

Lastly, before we get more into details, keep in mind that I use the bépo layout, as I often say in my different documents. This means the characters found in the numbers row when pressing shift are actually the numbers themselves. Also, some characters are not recognized as is by kbd, so we need to use a special name (not fun…). Below are the following characters:

Number Character
1 "
2 «
3 »
4 (
5 )
6 @
7 +
8 -
9 /
0 *

So if you see any weird keybind involving these characters, this is because of my layout.

Something a bit annoying though is Lisp doesnt know some characters by their actual name, rather by another one that I find too long and too bothersome to remember. So heres a list, if you see any of the characters on the left column in my config, with the function described below, my actual config will use their name as specified in the right column.

Character Name
« guillemotleft
» guillemotright
;; chars
(let ((filter (lambda (str)
                (replace-regexp-in-string "^~\\|~$" "" str))))
  (mapcar (lambda (row)
            `(,(apply filter `(,(car row))) . ,(apply filter `(,(cadr row)))))
          chars))
(("«" . "guillemotleft") ("»" . "guillemotright"))

To convert these characters, I have my own macro which is a wrapper around the function kbd.

(defun my/kbd (keys)
  "Prepares KEYS for function `stumpwm:kbd'.
If a character declared in the car of a member of the variable char,
it is replaced with its cdr. This allows the user to input characters
such as « or » and have them replaced with their actual name when
`stumpwm:kbd' is called."
  (kbd (let ((chars '<<chars-table-to-list()>>))
           (dolist (row chars keys)
             (setf keys (cl-ppcre:regex-replace-all (car row) keys (cdr row)))))))
<<my-kbd-defun>>
<<my-kbd-defun>>

Applications

When I speak about applications, I speak about programs and scripts in general. With these keymaps, I can launch programs I have often use for, but I can also launch some scripts as well as take screenshots.

First, lets create my rofi scripts keymap.

Keychord Function
a exec awiki
r exec rofi -combi-modi drun,window -show combi
s exec rofi -show ssh
p exec rofi-pass -t
P exec rofi-pass
e exec rofi-emoji
m exec rofi-mount
u exec rofi-umount
w exec wacom-setup
y exec ytplay
Y exec rofi-ytdl
*my-rofi-keymap*

Heres the equivalent in Common Lisp.

(defvar *my-rofi-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=rofi-scripts)>>
    m))

Lets also create a keymap for screenshots.

Keychord Function
d exec flameshot gui -d 3000
s exec flameshot full
S exec flameshot gui
*my-screenshot-keymap*

Heres the equivalent in Common Lisp.

(defvar *my-screenshot-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=screenshot-keymap)>>
    m))

We can now define our applications keymap which will reference both the above keymaps.

Keychord Function
b firefox
B exec qutebrowser
d exec discord
e exec emacsclient -c
g exec gimp
n exec nemo
r '*my-rofi-keymap*
s '*my-screenshot-keymap*
w exec select-pape
*my-applications-keymap*

This translates to:

(defvar *my-applications-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=application-keymap)>>
    m))

The application keymap can now be bound to the root map like so:

(define-key *root-map* (my/kbd "a") '*my-applications-keymap*)

I will also bind to the top map s-RET in order to open a new terminal window. The screenshot keymap is also bound to the ScreenPrint key, and the XF86Mail key opens mu4e in Emacs.

(define-key *top-map* (my/kbd "s-RET") "term")
(define-key *top-map* (my/kbd "Print") '*my-screenshot-keymap*)
(define-key *top-map* (my/kbd "XF86Mail") "exec emacsclient -c -e \"(mu4e)\"")

End of Session, Powering Off, and the Likes

The module end-session provides functions for gracefully ending the user session, powering off, restarting, and suspending the computer. It also provides a function that interactively asks what the user wishes to do.

Keychord Function
q end-session
l logout
s suspend-computer
S shutdown-computer
r loadrc
R restart-hard
C-r restart-computer

This translates to:

(defvar *my-end-session-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=end-session-keymap)>>
    m))

Which is bound in the root map to q:

(define-key *root-map* (my/kbd "q") '*my-end-session-keymap*)

Groups

A basic keybind I need for groups is to be able to switch from one another. Im very used to the ability of being able to jump between them with the keybind Super + number of the group, so lets define this:

(mapconcat (lambda (group)
             (let ((group-nbr (nth 1 group)))
               (format "%S" `(define-key
                               ,(make-symbol map)
                               (my/kbd ,(format "%s-%s"
                                             mod
                                             (if (string= "yes" convert)
                                                 (format "<<num-to-char(num=%s)>>" group-nbr)
                                               (number-to-string group-nbr))))
                               ,(format "%s %d" action group-nbr)))))
           groups
           "\n")
"(define-key *top-map* (my/kbd \"s-1\") \"gselect 1\")
(define-key *top-map* (my/kbd \"s-2\") \"gselect 2\")
(define-key *top-map* (my/kbd \"s-3\") \"gselect 3\")
(define-key *top-map* (my/kbd \"s-4\") \"gselect 4\")
(define-key *top-map* (my/kbd \"s-5\") \"gselect 5\")"
<<group-keybind-gen(mod="s", action="gselect", convert="yes")>>
(define-key *top-map* (my/kbd "s-<<num-to-char(num=1)>>") "gselect 1")
(define-key *top-map* (my/kbd "s-<<num-to-char(num=2)>>") "gselect 2")
(define-key *top-map* (my/kbd "s-<<num-to-char(num=3)>>") "gselect 3")
(define-key *top-map* (my/kbd "s-<<num-to-char(num=4)>>") "gselect 4")
(define-key *top-map* (my/kbd "s-<<num-to-char(num=5)>>") "gselect 5")

Another batch of keybinds I use a lot is keybinds to send the currently active window to another group, using Super + Shift + number of the group. As mentioned before, due to my keyboard layout Shift + number is actually just number for me (e.g. Shift + " results in 1), so theres no need to convert the group number to another character.

<<group-keybind-gen(mod="s", action="gmove-and-follow", convert="no")>>
(define-key *top-map* (my/kbd "s-1") "gmove-and-follow 1")
(define-key *top-map* (my/kbd "s-2") "gmove-and-follow 2")
(define-key *top-map* (my/kbd "s-3") "gmove-and-follow 3")
(define-key *top-map* (my/kbd "s-4") "gmove-and-follow 4")
(define-key *top-map* (my/kbd "s-5") "gmove-and-follow 5")

If I want to send a window to another group without following it, Ill use s-S-C-<group number>, which gives us the following:

<<group-keybind-gen(mod="s-C", action="gmove-and-follow", convert="no")>>
(define-key *top-map* (my/kbd "s-C-1") "gmove-and-follow 1")
(define-key *top-map* (my/kbd "s-C-2") "gmove-and-follow 2")
(define-key *top-map* (my/kbd "s-C-3") "gmove-and-follow 3")
(define-key *top-map* (my/kbd "s-C-4") "gmove-and-follow 4")
(define-key *top-map* (my/kbd "s-C-5") "gmove-and-follow 5")

And if I want to bring the windows of another group into the current group, Ill use s-C-<group number>:

(define-key *top-map* (my/kbd "s-C-<<num-to-char(num=1)>>") "gmove-and-follow 1")
(define-key *top-map* (my/kbd "s-C-<<num-to-char(num=2)>>") "gmove-and-follow 2")
(define-key *top-map* (my/kbd "s-C-<<num-to-char(num=3)>>") "gmove-and-follow 3")
(define-key *top-map* (my/kbd "s-C-<<num-to-char(num=4)>>") "gmove-and-follow 4")
(define-key *top-map* (my/kbd "s-C-<<num-to-char(num=5)>>") "gmove-and-follow 5")

StumpWM also has already a nice keymap for managing groups called *groups-map*, so lets bind it to *root-map* too! (Its actually already bound, but since I plan on erasing *root-map* in the near future before binding stuff to it, I prefer to bind it already)

(define-key *root-map* (my/kbd "g") '*groups-map*)

And a binding to vgroups is done on *groups-map* in order to regroup similar keybinds.

(define-key *groups-map* (my/kbd "G") "vgroups")

I grew accustomed to s-ESC bringing me to the previous group when using AwesomeWM, so lets define that:

(define-key *top-map* (my/kbd "s-ESC") "gother")

Frames and Windows management

As youll see, I have loads of keybinds related to frames and windows management. They are all categorized in a specific keymap, called *my-frames-management-keymap*. But before that, lets define the keymap *my-frames-float-keymap*, with keybinds dedicated to actions related with floating windows and frames.

Keychord Function
f float-this
F unfloat-this
u unfloat-this
C-f flatten-floats
*my-frames-float-keymap*

We can now pass onto *my-frames-management-keymap*. My keybinds are organized this way:

Keychord Function
c move-focus left
t move-focus down
s move-focus up
r move-focus right
C move-window left
T move-window down
S move-window up
R move-window right
C-c exchange-direction left
C-t exchange-direction down
C-s exchange-direction up
C-r exchange-direction right
/ hsplit-and-focus
- vsplit-and-focus
h hsplit
v vsplit
H hsplit-equally
V vsplit-equally
. iresize
+ balance-frames
d remove-split
D only
e expose
f fullscreen
F '*my-frames-float-keymap*
i info
I show-window-properties
m meta
s sibling
u next-urgent
U unmaximize
*my-frames-management-keymap*

As you can see, with the binding to F, we make use of the *my-frames-float-keymap* keymap declared above, which means if we find ourselves in *my-frames-management-keymap*, pressing F will bring us in *my-frames-float-keymap*.

(defvar *my-frames-float-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=frames-float)>>
    m))

(defvar *my-frames-management-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=frames-and-window-management)>>
    m))

Lets bind *my-frames-management-keymap* in *root-keymap*:

(define-key *root-map* (my/kbd "w") '*my-frames-management-keymap*)

That way, if we want for instance to split our current frame vertically, well be able to type s-SPC w - and vsplit will be called.

I also bound a couple of these functions to the top keymap for easier access:

Keychord Function
s-c move-focus left
s-t move-focus down
s-s move-focus up
s-r move-focus right
s-C move-window left
s-T move-window down
s-S move-window up
s-R move-window right
s-M-c exchange-direction left
s-M-t exchange-direction down
s-M-s exchange-direction up
s-M-r exchange-direction right

This translates to:

<<keybinds-gen(map="*top-map*", keybinds=top-window-map)>>

Being a bépo layout user, the hjkl keys dont exactly fit me, as you might have noticed with my use of ctsr which is its equivalent. Due to this, the interactive keymap for iresize is not ideal for me, let me redefine it:

(define-interactive-keymap (iresize tile-group) (:on-enter #'setup-iresize
                                                 :on-exit  #'resize-unhide
                                                 :abort-if #'abort-resize-p
                                                 :exit-on  ((kbd "RET") (kbd "ESC")
                                                            (kbd "C-g") (kbd "q")))
  ((my/kbd "c") "resize-direction left")
  ((my/kbd "t") "resize-direction down")
  ((my/kbd "s") "resize-direction up")
  ((my/kbd "r") "resize-direction right"))

As with groups management, I grew used to s-TAB in AwesomeWM bringing me back to the previously focused window, and I also grew used to s-o doing the same thing.

(define-key *top-map* (my/kbd "s-TAB") "other-window")
(define-key *top-map* (my/kbd "s-o") "other-window")

Windows management

When it comes to windows management, I will treat them a bit like I do with Emacs buffers.

Keychord Function
b windowlist
d delete-window
D delete-window-and-frame
k kill-window
n next
o other-window
p prev
*my-buffers-management-keymap*
(defvar *my-buffers-management-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=window-management)>>
    m))

(define-key *root-map* (my/kbd "b") '*my-buffers-management-keymap*)

Media and Media Control

My music is managed through MPD, and I often use playerctl commands in order to interact with it without any GUI application. So, well see a lot of its usage here, and numerous commands used here come from the mpd minor mode loaded above.

First, lets declare an interactive keymap in order to easily change several times in a row either the current song playing or the volume of MPD.

Keychord Function
c mpd-prev
t mpd-volume-down
s mpd-volume-up
r mpd-next
Interactive keybinds for mpc

This can be translated in CommonLisp as:

<<interactive-gen(name="mpc-interactive", keys=inter-mpc)>>

We need to indicate also how much the volume is affected by mpd-volume-down and mpd-volume-up.

(setf *mpd-volume-step* 2)

Another one will be defined for the general audio of my computer. And I know it isnt technically media keybinds, but Ill add in keybinds for my screens backlight.

Keys Function
c exec xbacklight -perceived -dec 2
t exec amixer -q set Master 2%- unmute
s exec amixer -q set Master 2%+ unmute
r exec xbacklight -perceived -inc 2
m exec amixer -q set Master 1+ toggle
Interactive keybinds for general media interaction
<<interactive-gen(name="media-interactive", keys=inter-media)>>

Then, lets declare a keymap for our media controls.

Keychord Function
a mpd-search-and-add-artist
A mpd-search-and-add-album
f mpd-search-and-add-file
F mpd-add-file
g mpd-search-and-add-genre
t mpd-search-and-add-title
*my-mpd-add-map*
Keychord Function
a mpd-browse-artists
A mpd-browse-albums
g mpd-browse-genres
p mpd-browse-playlist
t mpd-browse-tracks
*my-mpd-browse-map*
Keychord Function
. media-interactive
« exec playerctl previous
» exec playerctl next
a '*my-mpd-add-map*
b '*my-mpd-browse-map*
c mpd-clear
m mpc-interactive
p exec playerctl play-pause
s exec playerctl stop
u mpd-update
n exec kitty ncmpcpp -q
v exec kitty ncmpcpp -qs visualizer
*my-media-keymap*

Lets translate this table in CommonLisp:

(defvar *my-mpd-add-map*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=mpd-add-map)>>
    m))

(defvar *my-mpd-browse-map*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=mpd-browse-map)>>
    m))

(defvar *my-media-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=media-management)>>
    m))

(define-key *root-map* (my/kbd "m") '*my-media-keymap*)

I will also define on *top-map* some basic volume management keybinds so that they are immediately accessible. Again, this isnt technically media-related, but Ill add keybinds for my screens backlight.

Keychord Function
XF86AudioPlay exec playerctl play-pause
XF86AudioPause exec playerctl pause
XF86AudioStop exec playerctl stop
XF86AudioPrev exec playerctl previous
XF86AudioNext exec playerctl next
XF86AudioRewind exec playerctl position -1
XF86AudioForward exec playerctl position +1
XF86AudioRaiseVolume exec pamixer -i 2
XF86AudioLowerVolume exec pamixer -d 2
XF86AudioMute exec pamixer -t
XF86MonBrightnessDown exec xbacklight -perceived -dec 2
XF86MonBrightnessUp exec xbacklight -perceived -inc 2
Top-level media keys
<<keybinds-gen(map="*top-map*", keybinds=media-top-level)>>

Misc

Finally, some misc keybinds on the root map which dont really fit anywhere else:

Keychord Function
SPC colon
B beckon
C-b banish
l exec plock
r reload
<<keybinds-gen(map="*root-map*", keybinds=misc-root-map)>>

From time to time, I need to switch between different keyboard layouts, especially to the US QWERTY layout when Im playing some games and the bépo layout most of the time. Ill use the command switch-layout defined above.

Keychord Function
b exec setxkbmap fr bepo_afnor
u exec setxkbmap us
(defvar *my-keyboard-layout-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=keyboard-layout-map)>>
    m))

(define-key *root-map* (my/kbd "k") '*my-keyboard-layout-keymap*)

Utilities

Part of my configuration is not really related to StumpWM itself, or rather it adds new behaviour StumpWM doesnt have. utilities.lisp stores all this code in one place.

Binwarp

Binwarp allows the user to control their mouse from the keyboard, basically eliminating the need for a physical mouse in daily usage of the workstation (though a physical mouse stays useful for games and such).

(load-module "binwarp")

Next, Ill define my keybinds for when using Binwarp for emulating mouse clicks as well as bépo-compatible mouse movements. This new Binwarp mode is now available from the keybind s-m at top level.

(binwarp:define-binwarp-mode my-binwarp-mode "s-m" (:map *top-map*)
    ((my/kbd "SPC") "ratclick 1")
    ((my/kbd "RET") "ratclick 3")
    ((my/kbd "c")   "binwarp left")
    ((my/kbd "t")   "binwarp down")
    ((my/kbd "s")   "binwarp up")
    ((my/kbd "r")   "binwarp right")
    ((my/kbd "i")   "init-binwarp")
    ((my/kbd "q")   "exit-binwarp"))

Bluetooth

Although there is a Bluetooth module for the modeline, this is about the extent to which StumpWM can interact with the systems Bluetooth. However, I wish for some more interactivity, like powering on and off Bluetooth, connecting to devices and so on.

Firstly, our code relies on cl-ppcre, so lets quickload it.

(ql:quickload :cl-ppcre)

Lets indicate which command well be using.

(defvar *bluetooth-command* "bluetoothctl"
  "Base command for interacting with bluetooth.")
Utilities

Well need a couple of functions that will take care of stuff for us, so we dont have to repeat ourselves. The first one is a way for us to share a message. The function bluetooth-message will first display Bluetooth: in green, then it will display the message we want it to display.

(defun bluetooth-message (&rest message)
  (message (format nil
                   "^2Bluetooth:^7 ~{~A~^ ~}"
                   message)))

This function is a builder function which will create our commands. For instance, src_lisp[:exports code]{(bluetooth-make-command "power" "on")} will return "bluetoothctl power on" with *bluetooth-ctl* set as "bluetoothctl" — simply put, it joins *bluetooth-command* with args with a space as their separator.

(defun bluetooth-make-command (&rest args)
  (format nil
          "~a ~{~A~^ ~}"
          ,*bluetooth-command*
          args))

Now we can put bluetooth-make-command to use with bluetooth-command which will actually run the result of the former. As you can see, it also collects the output, so we can display it later in another function.

(defmacro bluetooth-command (&rest args)
  `(run-shell-command (bluetooth-make-command ,@args) t))

Finally, bluetooth-message-command is the function that both executes and also displays the result of the bluetooth command we wanted to see executed. Each argument of the command is a separate string. For instance, if we want to power on the bluetooth on our device, we can call src_lisp[:exports code]{(bluetooth-message-command "power" "on")}.

(defmacro bluetooth-message-command (&rest args)
  `(bluetooth-message (bluetooth-command ,@args)))
Toggle Bluetooth On and Off

This part is easy. Now that we can call our Bluetooth commands easily, we can easily define how to turn on Bluetooth.

(defcommand bluetooth-turn-on () ()
  "Turn on bluetooth."
  (bluetooth-message-command "power" "on"))

And how to power it off.

(defcommand bluetooth-turn-off () ()
  "Turn off bluetooth."
  (bluetooth-message-command "power" "off"))
Bluetooth Devices

In order to manipulate Bluetooth device, which we can represent as a MAC address and a name, we can create a structure that will make use of a constructor for simpler use. The constructor make-bluetooth-device-from-command expects an entry such as Device 00:00:00:00:00:00 Home Speaker. The constructor discards the term Device and stores the MAC address separately from the rest of the string which is assumed to be the full name of the device.

(defstruct (bluetooth-device
             (:constructor
              make-bluetooth-device (&key (address "")
                                          (name nil)))
             (:constructor
              make-bluetooth-device-from-command
              (&key (raw-name "")
               &aux (address (cadr (cl-ppcre:split " " raw-name)))
                    (full-name (format nil "~{~A~^ ~}" (cddr (cl-ppcre:split " " raw-name)))))))
  address
  (full-name (progn
                 (format nil "~{~A~^ ~}" name))))

We can now collect our devices easily.

(defun bluetooth-get-devices ()
  (let ((literal-devices (bluetooth-command "devices")))
    (mapcar (lambda (device)
              (make-bluetooth-device-from-command :raw-name device))
     (cl-ppcre:split "\\n" literal-devices))))
Connect to a device

When we want to connect to a Bluetooth device, we always need Bluetooth turned on, so bluetooth-turn-on will always be called. Then the function will attempt to connect to the device specified by the device argument, whether the argument is a Bluetooth structure as defined above or a plain MAC address.

(defun bluetooth-connect-device (device)
  (progn
    (bluetooth-turn-on)
    (cond ((bluetooth-device-p device) ;; it is a bluetooth-device structure
           (bluetooth-message-command "connect"
                                      (bluetooth-device-address device)))
          ((stringp device)            ;; assume it is a MAC address
           (bluetooth-message-command "connect" device))
          (t (message (format nil "Cannot work with device ~a" device))))))

The command to connect to a device displays a choice between the collected Bluetooth device and the user only has to select it. It will then attempt to connect to it.

(defcommand bluetooth-connect () ()
  (sb-thread:make-thread
   (lambda ()
    (let* ((devices (bluetooth-get-devices))
           (choice  (cadr (stumpwm:select-from-menu
                           (stumpwm:current-screen)
                           (mapcar (lambda (device)
                                     `(,(bluetooth-device-full-name device) ,device))
                                   devices)))))
      (bluetooth-connect-device choice)))))
Keybinds

Its all nice and all, but typing manually the commands with s-SPC ; is a bit tiring, so lets define our Bluetooth keymap which we will bind to s-SPC B.

Keychord Command
c bluetooth-connect
o bluetooth-turn-on
O bluetooth-turn-off
(defvar *my-bluetooth-keymap*
  (let ((m (make-sparse-keymap)))
    <<keybinds-gen(map="m", keybinds=bluetooth-keymap)>>
    m))

(define-key *root-map* (my/kbd "B") '*my-bluetooth-keymap*)

NetworkManager integration

It is possible to have some kind of integration between StumpWM and NetworkManager. To do so, we have to load the related module, then create the two keybinds described in /phundrak/config.phundrak.com/src/commit/760423c03b30c8bdb5b60bcdd9283464e48355f9/docs/nm-keybinds.

Keychord Command
W nm-list-wireless-networks
*my-nm-keybinds*

A call to

(ql:quickload :dbus)
is necessary for this module. Installing the dbus module in turn requires the library libfixposix installed on the users machine. On Arch, you can install it like so using paru:

paru -S libfixposix --noconfirm
(ql:quickload :dbus)

(load-module "stump-nm")

<<keybinds-gen(map="*root-map*", keybinds=nm-keybinds)>>

Pinentry

Out with GTK2s pinentry program! Lets use StumpWMs! At least thats what Id like to say, but unfortunately there is a bug in the text reading devices of StumpWM that prevent the user from using modifiers when entering a password such as AltGr, so I cant use it : /

;; (load-module "pinentry")

Sly

Sly is a fork of SLIME with which I can connect StumpWM and Emacs together. Technically this is already done to some level with stumpwm-mode, but the latter doesnt provide auto-completion or stuff like that.

The first thing to do is load slynk, SLYs server:

(ql:quickload :slynk)

Now we can define a command to launch the server. I dont want it to run all the time, just when I need it.

(stumpwm:defcommand sly-start-server () ()
  "Start a slynk server for sly."
  (sb-thread:make-thread (lambda () (slynk:create-server :dont-close t))))

(stumpwm:defcommand sly-stop-server () ()
  "Stop current slynk server for sly."
  (sb-thread:make-thread (lambda () (slynk:stop-server 4005))))

swm-ssh

This module from the contrib repository scans the users ssh configuration file and offers them a quick way of connecting to their remote hosts.

(load-module "swm-ssh")

The default terminal needs to be set, otherwise the module will try to call urxvtc which is not installed on my system.

(setq swm-ssh:*swm-ssh-default-term* "kitty")

Now, to call the main command of this module we can define the following keybind.

(define-key *root-map* (my/kbd "s") "swm-ssh-menu")

Systemd

Im currently in the process of writing functions to interact with Systemd directly through StumpWM. For now, not much work is done, but its a start.

Firstly, I have the following function that lists all the system or user services.

(defun systemd-get-services (&key user-p)
  "Collect all systemd services running.

If USER-P is t, collect user services, otherwise collect system
services.

The value returned is a list of lists. The first element is the
services name, the second is its load state, the third the high-level
activation state of the service, and the fourth its low-level
activation state."
  (mapcar (lambda (elt)
            (multiple-value-bind (_ result)
                (ppcre:scan-to-strings "(.*\\.service) *([^ ]+) *([^ ]+) *([^ ]+).*"
                                       elt)
              result))
          (ppcre:split
           " *\\n●? *"
           (ppcre:regex-replace
            "^ *"
            (run-shell-command (concat "systemctl list-units --type service --all -q"
                                       (if user-p " --user" ""))
                               t)
            ""))))

The only command I have right now is for listing the system or user services with message. Unfortunately, if there are too many services, the list will overflow the screen. I do not know how to fix that yet. I set the timeout to 600 seconds in order to have all the time in the world to read the services list. It goes away as soon as something else appears, such as a s-SPC C-g since I have which-key-mode enabled.

(defcommand systemd-list-services (user-p) ((:y-or-n "User services? "))
  (let ((stumpwm::*timeout-wait* 600))
   (message (format nil "~{~a~^~&~}"
                    (mapcar (lambda (service)
                              (let ((name (aref service 0))
                                    (load (aref service 1))
                                    (active (aref service 2))
                                    (sub (aref service 3)))
                                (cond ((member load '("not-found" "bad-setting"
                                                      "error" "masked")
                                               :test #'string=)
                                       (format nil
                                               "^~A~A^0 ^>  Load: ~12@A"
                                               (if (string= "masked" load) 4 1)
                                               name load))
                                      ((member active '("failed" "reloading" "activating"
                                                        "deactivating" "inactive")
                                               :test #'string=)
                                       (format nil "^~A~A^0 ^>Active: ~12@A"
                                               (case active
                                                 ("failed" 1)
                                                 ("inactive" 0)
                                                 (t 3))
                                               name
                                               active))
                                      (t (format nil "^2~A^0 ^>   Sub: ~12@A" name sub)))))
                            (systemd-get-services :user-p user-p))))))