EXWM

So, I’m finally slowly getting back to EXWM. I tried it a couple of years ago, but that was with the SpacemacsOS layer on Spacemacs, on a laptop which got accidentally formatted before I could save my config and all… So it got me some time to come back. I’m still a bit worried about Emacs being single threaded, so if I get one blocking function blocking Emacs, my whole desktop will hang, but for now I haven’t had this issue.

All my EXWM config is enabled only if I launch Emacs with the argument --with-exwm, otherwise none of the related packages get installed, let alone activated and made available.

First, I need to install the X protocol Emacs Lisp Bindings. It doesn’t seem to be available in any repo, so I’ll install it directly from Git.

(use-package xelb
  :if (seq-contains-p command-line-args "--with-exwm")
  :straight (xelb :build t
                  :type git
                  :host github
                  :repo "emacs-straight/xelb"
                  :fork "ch11ng/xelb"))

Next is a function I’ve stolen copied from Daviwil’s desktop configurationopen in new window. This allows to launch software in the background easily.

(defun exwm/run-in-background (command &optional once)
  (let ((command-parts (split-string command " +")))
    (apply #'call-process `(,(car command-parts) nil 0 nil ,@(cdr command-parts)))))

In order to launch Emacs with EXWM with startx, I need a xinit file. This one is exported to $HOME/.xinitrc.emacs.

xhost +SI:localuser:$USER

# Set fallback cursor
xsetroot -cursor_name left_ptr

# If Emacs is started in server mode, `emacsclient` is a convenient
# way to edit files in place (used by e.g. `git commit`)
export VISUAL=emacsclient
export EDITOR="$VISUAL"

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

autorandr -l home

exec emacs --with-exwm

EXWM itself

Now we come to the plat de resistance. Like with xelb, I’m using its Git source to install it to make sure I get the right version — the version available on the GNU ELPA is from the same source, true, but I don’t know at which rate it is updated. And more packages down the line will depend on this Git repository, so I might as well just clone it right now.

As you can see, I added in the :config section to two hooks functions that rename buffers accurately. While the average X window will simply get the name of the current X window, I want Firefox and Qutebrowser to be prefixed with the name of the browser. Actually, all these will be renamed this way:

  • Kitty
  • Qutebrowser
(add-hook 'exwm-update-class-hook
          (lambda () (exwm-workspace-rename-buffer exwm-class-name)))

(add-hook 'exwm-update-title-hook
          (lambda ()
            (pcase exwm-class-name
              <<exwm-gen-buffers-rename()>>)))

As you can see below, in the :config section I added two advices and one hook in order to correctly integrate evil with EXWM. When I’m in an X window, I want to be in insert-mode in order to type however I want. However, when I exit one, I want to default back to normal-mode.

(add-hook 'exwm-manage-finish-hook (lambda () (call-interactively #'exwm-input-release-keyboard)))
(advice-add #'exwm-input-grab-keyboard :after (lambda (&optional id) (evil-normal-state)))
(advice-add #'exwm-input-release-keyboard :after (lambda (&optional id) (evil-insert-state)))

Secondly, I add i, C-SPC, and M-m as exwm prefix keys, so they aren’t sent directly to the X windows but caught by Emacs (and EXWM). I’ll use the i key in normal-mode to enter insert-mode and have Emacs release the keyboard so the X window can grab it. Initially, I had s-<escape> as a keybind for grabbing back the keyboard from an X window, as if I were in insert mode and wanted to go back to normal mode, and I had s-I to toggle keyboard grabbing. But I found myself more than once trying to use s-<escape> to toggle this state, s-I completely forgotten. So I removed s-I and made s-<escape> behave like s-I once did.

(general-define-key
 :keymaps 'exwm-mode-map
 :states 'normal
 "i" #'exwm-input-release-keyboard)

(exwm-input-set-key (kbd "s-<escape>") #'exwm-input-toggle-keyboard)

(push ?\i exwm-input-prefix-keys)
(push (kbd "C-SPC") exwm-input-prefix-keys)
(push (kbd "M-m") exwm-input-prefix-keys)

As stated a couple of times in my different configuration files, I’m using the bépo layout, which means the default keys in the number row are laid as follows:

(defconst exwm-workspace-keys '("\"" "«" "»" "(" ")" "@" "+" "-" "/" "*"))

With this, we can create keybinds for going or sending X windows to workspaces 0 to 9.

(setq exwm-input-global-keys
      `(,@exwm-input-global-keys
        ,@(mapcar (lambda (i)
                    `(,(kbd (format "s-%s" (nth i exwm-workspace-keys))) .
                      (lambda ()
                        (interactive)
                        (exwm-workspace-switch-create ,i))))
                  (number-sequence 0 9))
        ,@(mapcar (lambda (i)
                    `(,(kbd (format "s-%d" i)) .
                      (lambda ()
                        (interactive)
                        (exwm-workspace-move-window ,(let ((index (1- i)))
                                                       (if (< index 0)
                                                           (- 10 index)
                                                         ;; FIXME: does not work with s-0
                                                         index))))))
                  (number-sequence 0 9))))

You can then see the list of the keybinds I have set for EXWM, which are all prefixed with SPC x in normal mode (and C-SPC x in insert mode), except for s-RET which opens an eshell terminal.

(exwm-input-set-key (kbd "s-<return>") (lambda ()
                                         (interactive)
                                         (eshell)))

(phundrak/leader-key
  :infix "x"
  ""   '(:ignore t :which-key "EXWM")
  "d"  #'exwm-debug
  "k"  #'exwm-input-send-next-key
  "l"  '((lambda ()
           (interactive)
           (start-process "" nil "plock"))
         :which-key "lock")
  "r"  '(:ignore t :wk "rofi")
  "rr" '((lambda () (interactive)
           (shell-command "rofi -show drun" (current-buffer) (current-buffer)))
         :wk "drun")
  "rw" '((lambda () (interactive)
           (shell-command "rofi -show window" (current-buffer) (current-buffer)))
         :wk "windows")
  "R"  '(:ignore t :wk "restart")
  "Rr" #'exwm-reset
  "RR" #'exwm-restart
  "t"  '(:ignore t :which-key "toggle")
  "tf" #'exwm-layout-toggle-fullscreen
  "tF" #'exwm-floating-toggle-floating
  "tm" #'exwm-layout-toggle-mode-line
  "w"  '(:ignore t :which-key "workspaces")
  "wa" #'exwm-workspace-add
  "wd" #'exwm-workspace-delete
  "ws" #'exwm-workspace-switch
  "x"  '((lambda ()
           (interactive)
           (let ((command (string-trim (read-shell-command "RUN: "))))
             (start-process command nil command)))
         :which-key "run")
  "RET" #'eshell-new)

A couple of commands are also automatically executed through my autostart script written here.

(exwm/run-in-background "autostart")

Finally, let’s only initialize and start EXWM once functions from exwm-randr ran, because otherwise having multiple monitors don’t work.

(with-eval-after-load 'exwm-randr
  (exwm-init))

The complete configuration for the exwm package can be found below.

(use-package exwm
  :if (seq-contains-p command-line-args "--with-exwm")
  :straight (exwm :build t
                  :type git
                  :host github
                  :repo "ch11ng/exwm")
  :custom
  (use-dialog-box nil "Disable dialog boxes since they are unusable in EXWM")
  (exwm-input-line-mode-passthrough t "Pass all keypresses to emacs in line mode.")
  :init
  (require 'exwm-config)
  (setq exwm-workspace-number 6)
  :config
  (set-frame-parameter (selected-frame) 'alpha-background 0.7)
  (require 'exwm-randr)
  (exwm/run-in-background "xwallpaper --zoom \"${cat $HOME/.cache/wallpaper}\"")
  (start-process-shell-command
   "xrandr" nil "xrandr --output eDP1 --mode 1920x1080 --pos 2560x0 --rotate normal --output HDMI1 --primary --mode 2560x1080 --pos 0x0 --rotate normal --output VIRTUAL1 --off --output DP-1-0 --off --output DP-1-1 --off")
  (exwm-randr-enable)
  (setq exwm-randr-workspace-monitor-plist '(3 "eDP1"))

  (add-hook 'exwm-update-class-hook
            (lambda () (exwm-workspace-rename-buffer exwm-class-name)))

  (add-hook 'exwm-update-title-hook
            (lambda ()
              (pcase exwm-class-name
                ("kitty" (exwm-workspace-rename-buffer (concat "EXWM: Kitty - " exwm-title)))
                ("qutebrowser" (exwm-workspace-rename-buffer (concat "EXWM: Qutebrowser - " exwm-title)))
                (_otherwise (exwm-workspace-rename-buffer exwm-title)))))

  (add-hook 'exwm-manage-finish-hook (lambda () (call-interactively #'exwm-input-release-keyboard)))
  (advice-add #'exwm-input-grab-keyboard :after (lambda (&optional id) (evil-normal-state)))
  (advice-add #'exwm-input-release-keyboard :after (lambda (&optional id) (evil-insert-state)))
  (general-define-key
   :keymaps 'exwm-mode-map
   :states 'normal
   "i" #'exwm-input-release-keyboard)

  (exwm-input-set-key (kbd "s-<escape>") #'exwm-input-toggle-keyboard)

  (push ?\i exwm-input-prefix-keys)
  (push (kbd "C-SPC") exwm-input-prefix-keys)
  (push (kbd "M-m") exwm-input-prefix-keys)

  (defconst exwm-workspace-keys '("\"" "«" "»" "(" ")" "@" "+" "-" "/" "*"))
  (setq exwm-input-global-keys
        `(,@exwm-input-global-keys
          ,@(mapcar (lambda (i)
                      `(,(kbd (format "s-%s" (nth i exwm-workspace-keys))) .
                        (lambda ()
                          (interactive)
                          (exwm-workspace-switch-create ,i))))
                    (number-sequence 0 9))
          ,@(mapcar (lambda (i)
                      `(,(kbd (format "s-%d" i)) .
                        (lambda ()
                          (interactive)
                          (exwm-workspace-move-window ,(let ((index (1- i)))
                                                         (if (< index 0)
                                                             (- 10 index)
                                                           ;; FIXME: does not work with s-0
                                                           index))))))
                    (number-sequence 0 9))))

  (exwm-input-set-key (kbd "s-<return>") (lambda ()
                                           (interactive)
                                           (eshell)))

  (phundrak/leader-key
    :infix "x"
    ""   '(:ignore t :which-key "EXWM")
    "d"  #'exwm-debug
    "k"  #'exwm-input-send-next-key
    "l"  '((lambda ()
             (interactive)
             (start-process "" nil "plock"))
           :which-key "lock")
    "r"  '(:ignore t :wk "rofi")
    "rr" '((lambda () (interactive)
             (shell-command "rofi -show drun" (current-buffer) (current-buffer)))
           :wk "drun")
    "rw" '((lambda () (interactive)
             (shell-command "rofi -show window" (current-buffer) (current-buffer)))
           :wk "windows")
    "R"  '(:ignore t :wk "restart")
    "Rr" #'exwm-reset
    "RR" #'exwm-restart
    "t"  '(:ignore t :which-key "toggle")
    "tf" #'exwm-layout-toggle-fullscreen
    "tF" #'exwm-floating-toggle-floating
    "tm" #'exwm-layout-toggle-mode-line
    "w"  '(:ignore t :which-key "workspaces")
    "wa" #'exwm-workspace-add
    "wd" #'exwm-workspace-delete
    "ws" #'exwm-workspace-switch
    "x"  '((lambda ()
             (interactive)
             (let ((command (string-trim (read-shell-command "RUN: "))))
               (start-process command nil command)))
           :which-key "run")
    "RET" #'eshell-new)

  (exwm/run-in-background "autostart")

  (with-eval-after-load 'exwm-randr
    (exwm-init)))

EXWM-Evil integration

(use-package evil-exwm-state
  :if (seq-contains-p command-line-args "--with-exwm")
  :defer t
  :after exwm
  :straight (evil-exwm-state :build t
                             :type git
                             :host github
                             :repo "domenzain/evil-exwm-state"))

Multimonitor support

(require 'exwm-randr)
(exwm/run-in-background "xwallpaper --zoom \"${cat $HOME/.cache/wallpaper}\"")
(start-process-shell-command
 "xrandr" nil "xrandr --output eDP1 --mode 1920x1080 --pos 2560x0 --rotate normal --output HDMI1 --primary --mode 2560x1080 --pos 0x0 --rotate normal --output VIRTUAL1 --off --output DP-1-0 --off --output DP-1-1 --off")
(exwm-randr-enable)
(setq exwm-randr-workspace-monitor-plist '(3 "eDP1"))

Keybinds for a desktop environment

(use-package desktop-environment
  :defer t
  :straight (desktop-environment :build t
                                 :type git
                                 :host github
                                 :repo "DamienCassou/desktop-environment")
  :after exwm
  :diminish t
  :config
  (add-hook 'exwm-init-hook #'desktop-environment-mode)
  (setq desktop-environment-update-exwm-global-keys :prefix
        exwm-layout-show-al-buffers t)

  (setq desktop-environment-bluetooth-command "bluetoothctl"

        desktop-environment-brightness-get-command      "xbacklight -get"
        desktop-environment-brightness-get-regexp       (rx line-start (group (+ digit)))
        desktop-environment-brightness-set-command      "xbacklight %s"
        desktop-environment-brightness-normal-increment "-inc 5"
        desktop-environment-brightness-normal-decrement "-dec 5"
        desktop-environment-brightness-small-increment  "-inc 2"
        desktop-environment-brightness-small-decrement  "-dec 2"

        desktop-environment-volume-normal-decrement "-d 5"
        desktop-environment-volume-normal-increment "-i 5"
        desktop-environment-volume-small-decrement  "-d 2"
        desktop-environment-volume-small-increment  "-i 2"
        desktop-environment-volume-set-command      "pamixer -u %s"

        desktop-environment-screenshot-directory "~/Pictures/Screenshots"
        desktop-environment-screenlock-command   "plock"

        desktop-environment-music-toggle-command   "mpc toggle"
        desktop-environment-music-previous-command "mpc prev"
        desktop-environment-music-next-command     "mpc next"
        desktop-environment-music-stop-command     "mpc stop")

  (general-define-key
   "<XF86AudioPause>" (lambda () (interactive)
                        (with-temp-buffer
                           (shell-command "mpc pause" (current-buffer) (current-buffer)))))

  (desktop-environment-mode))

Bluetooth

(defvar bluetooth-command "bluetoothctl")
(defun bluetooth-turn-on ()
  (interactive)
  (let ((process-connection-type nil))
    (start-process "" nil bluetooth-command "power" "on")))
(defun bluetooth-turn-off ()
  (interactive)
  (let ((process-connection-type nil))
    (start-process "" nil bluetooth-command "power" "off")))
(defun create-bluetooth-device (raw-name)
  "Create a Bluetooth device cons from RAW NAME.
The cons will hold first the MAC address of the device, then its
human-friendly name."
  (let ((split-name (split-string raw-name " " t)))
    `(,(mapconcat #'identity (cddr split-name) " ") . ,(cadr split-name))))
(require 'dbus)
(defun bluetooth-get-devices ()
  (let ((bus-list (dbus-introspect-get-node-names :system "org.bluez" "/org/bluez/hci0")))
    (mapcar (lambda (device)
              `(,(dbus-get-property :system
                                    "org.bluez"
                                    (concat "/org/bluez/hci0/" device)
                                    "org.bluez.Device1"
                                    "Alias")
                . ,device))
            bus-list)))
(defun bluetooth-connect-device ()
  (interactive)
  (progn
    (bluetooth-turn-on)
    (let* ((devices (bluetooth-get-devices))
           (device  (alist-get (completing-read "Device: " devices)
                               devices nil nil #'string=)))
      (dbus-call-method-asynchronously
       :system "org.bluez"
       (concat "/org/bluez/hci0" device)
       "org.bluez.Device1"
       "Connect"
       (lambda (&optional msg)
         (when msg (message "%s" msg)))))))