Using EXWM and perspective.el on multi-monitor setup

Using EXWM and perspective.el on multi-monitor setup

I wrote about Emacs and i3 integration around two months ago. Shortly after however, I decided to give EXWM another try, mainly because my largest reservation - lack of performance - seems to have been resolved by updates to the native compilation since my first attempt. Or I may have lost some sensitivity to that issue. Regardless, the second dive into EXWM thus far feels successful, and I think it’s the right time to share some of my thoughts on the subject.

Before we start though, I’ll point out that I won’t go into detail about the initial setup. I think David Wilson’s “Emacs Desktop Environment” series describes this part pretty well, so I don’t feel the need to repeat much of that.

This post is a sort of a snapshot of the path from the baseline of Emacs From Scratch to my image of a perfect window manager, and it may or may not be coincidental that the latter resembles i3 in many aspects.

After all, I was using i3 for more than two years, so it’s not something I can easily let go of. But I think (or would like to think) that’s because the ideas are good, not because I’m overly conservative in my workflow choices.

perspective.el

perspective.el is one package I like that provides workspaces for Emacs, called “perspectives”. Each perspective has a separate buffer list, window layout, and a few other things that make it easier to separate things within Emacs.

One feature I’d like to highlight is integration between perspective.el and treemacs, where one perspective can have a separate treemacs tree. Although now tab-bar.el seems to be getting into shape to compete with perspective.el, as of the time of this writing, there’s no such integration, at least not out of the box.

perspective.el works with EXWM more or less as one would expect - each EXWM workspace has its own set of perspectives. That way it feels somewhat like having multiple Emacs frames in a tiling window manager, although, of course, much more integrated with Emacs.

However, there are still some issues. For instance, I was having strange behaviors with floating windows, EXWM buffers in perspectives, etc. So I’ve made a package called perspective-exwm.el that does two things:

  • Fixes issues I found with some advises and hooks. Take a look at the package homepage for more detail on that.
  • Provides some additional functionality that makes use of both perspective.el and EXWM.

So, you can install the package however you normally do so. E.g. I do that with straight.el & use-package:

(use-package perspective-exwm
  :straight t
  :config
  ...)

Then load the provided minor mode before exwm-init:

(use-package exwm
  :config
  ...
  (perspective-exwm-mode)
  (exwm-init))

Initial perspective names

One nice thing this package can do is set up the initial perspective names for different workspaces. By default, enabling perspective-exwm-mode sets names like main-1 for workspace with index 1 and so on, because otherwise different perspectives will share the same *scratch* buffer.

But names can be overridden like that:

(setq perspective-exwm-override-initial-name
      '((0 . "misc")
        (1 . "core")
        (2 . "browser")
        (3 . "comms")
        (4 . "dev")))

Assigning apps to workspaces and perspectives

By default, a new Emacs buffer opens in the current perspective in the current workspace, but sure enough, it’s possible to change that.

For EXWM windows, the perspective-exwm package provides a function called perspective-exwm-assign-window, which is intended to be used in exwm-manage-finish-hook, for instance:

(defun my/exwm-configure-window ()
  (interactive)
  (pcase exwm-class-name
    ((or "Firefox" "Nightly")
     (perspective-exwm-assign-window
      :workspace-index 2
      :persp-name "browser"))
    ("Alacritty"
     (perspective-exwm-assign-window
      :persp-name "term"))))

(add-hook 'exwm-manage-finish-hook #'my/exwm-configure-window)

This hook is run after a new EXWM buffer is created and configured in the context of this buffer, so it seems customary to do such settings there. With this snippet, Firefox will always open in workspace 2 in the perspective named “browser”, and Alacritty will always open in the current workspace in the perspective named “term”.

To pull this off for various Emacs apps, it is necessary to open the right EXWM workspace and perspective before opening the app. As I use general.el, I made a macro to automate that:

(defmacro my/command-in-persp (command-name persp-name workspace-index &rest args)
  `'((lambda ()
       (interactive)
       (when (and ,workspace-index (fboundp #'exwm-workspace-switch-create))
         (exwm-workspace-switch-create ,workspace-index))
       (persp-switch ,persp-name)
       (delete-other-windows)
       ,@args)
     :wk ,command-name))

fboundp is meant to provide compatibility with running Emacs without EXWM. Usage of the macro is as follows:

(my-leader-def
  :infix "as"
  "" '(:which-key "emms")
  "s" (my/command-in-persp "emms" "EMMS" 0 (emms-smart-browse))
  ...)

my-leader-def is a custom definer. That way the defined keybinding opens EMMS in the workspace 0 in the perspective “EMMS”. I have this for several other apps, like elfeed, notmuch, dired $HOME and so on.

Some workflow notes

As I said above, using perspectives in EXWM makes a lot of sense. Because all the EXWM workspace share the same buffer list (sans X windows), and because Emacs becomes the central program (for instance, it can’t be easily closed), it is only natural to split the buffer list.

Another aspect of using EXWM is that it becomes very easy to work with code on multiple monitors. While it may signify issues with the code in question if such need arises, having that possibility is still handy and it’s not something easily replicable on other tiling WMs. perspective-exwm also presents some features here, for instance, M-x perspective-exwm-copy-to-workspace can be used to copy the current perspective to the adjacent monitor.

Also, in my opinion, Emacs apps like EMMS and elfeed deserve to be on the same “level” as “proper” apps like a browser. On other tiling WMs, something like that can be done with Emacs daemon and multiple Emacs frames, but with EXWM and perspectives this seems natural without much extra work.

As for switching between X windows and perspectives, I ended up preferring to have one perspective for all X windows in the workspace, at least if these windows are full-fledged apps. For instance, all my messengers go to the workspace 3 to the perspective “comms”, and I switch between them with M-x perspective-exwm-cycle-exwm-buffers-<forward|backward>, bound to s-[ and s-]. For switching perspectives, I’ve bound s-, and s-.:

(setq exwm-input-global-keys
      `(
        ...
        ;; Switch perspectives
        (,(kbd "s-,") . persp-prev)
        (,(kbd "s-.") . persp-next)

        ;; EXWM buffers
        (,(kbd "s-[") . perspective-exwm-cycle-exwm-buffers-backward)
        (,(kbd "s-]") . perspective-exwm-cycle-exwm-buffers-forward)
        ...)

Workspaces on multiple monitors

Here, exwm-randr provides basic functionality for running EXWM on multiple monitors. For instance, with configuration like that:

(require 'exwm-randr)
(exwm-randr-enable)
;; The script is generated by ARandR
(start-process-shell-command "xrandr" nil "~/bin/scripts/screen-layout")
(when (string= (system-name) "indigo")
  (setq exwm-randr-workspace-monitor-plist '(2 "DVI-D-0" 3 "DVI-D-0")))

...
(exwm-init)

workspaces 2 and 3 on the machine with hostname “indigo” will be displayed on the monitor DVI-D-0.

However, some features, common in other tiling WMs, are missing in EXWM out of the box, namely:

Here’s my take on implementing them.

Tracking recently used workspaces

First up though, we need to track the workspaces in the usage order. I’m not sure if there’s some built-in functionality in EXWM for that, but it seems simple enough to implement.

Here is a snippet of code that does it:

(setq my/exwm-last-workspaces '(1))

(defun my/exwm-store-last-workspace ()
  "Save the last workspace to `my/exwm-last-workspaces'."
  (setq my/exwm-last-workspaces
        (seq-uniq (cons exwm-workspace-current-index
                        my/exwm-last-workspaces))))

(add-hook 'exwm-workspace-switch-hook
          #'my/exwm-store-last-workspace)

The variable my/exwm-last-workspaces stores the workspace indices; the first item is the index of the current workspace, the second item is the index of the previous workspace, and so on.

One note here is that workspaces may also disappear (e.g. after M-x exwm-workspace-delete), so we also need a function to clean the list:

(defun my/exwm-last-workspaces-clear ()
  "Clean `my/exwm-last-workspaces' from deleted workspaces."
  (setq my/exwm-last-workspaces
        (seq-filter
         (lambda (i) (nth i exwm-workspace--list))
         my/exwm-last-workspaces)))

The monitor list

The second piece of the puzzle is getting the monitor list in the right order.

While it is possible to retrieve the monitor list from exwm-randr-workspace-output-plist, this won’t scale well beyond two monitors, mainly because changing this variable may screw up the order.

So the easiest way is to just define the variable like that:

(setq my/exwm-monitor-list
      (pcase (system-name)
        ("indigo" '(nil "DVI-D-0"))
        (_ '(nil))))

If you are changing the RandR configuration on the fly, this variable will also need to be changed, but for now, I don’t have such a necessity.

A function to get the current monitor:

(defun my/exwm-get-current-monitor ()
  "Return the current monitor name or nil."
  (plist-get exwm-randr-workspace-output-plist
             (cl-position (selected-frame)
                          exwm-workspace--list)))

And a function to cycle the monitor list in either direction:

(defun my/exwm-get-other-monitor (dir)
  "Cycle the monitor list in the direction DIR.

DIR is either 'left or 'right."
  (nth
   (% (+ (cl-position
          (my/exwm-get-current-monitor)
          my/exwm-monitor-list
          :test #'string-equal)
         (length my/exwm-monitor-list)
         (pcase dir
           ('right 1)
           ('left -1)))
      (length my/exwm-monitor-list))
   my/exwm-monitor-list))

Switch to another monitor

With the functions from the previous two sections, we can implement switching to another monitor by switching to the most recently used workspace on that monitor.

One caveat here is that on the startup the my/exwm-last-workspaces variable won’t have any values from other monitor(s), so this list is concatenated with the list of available workspace indices.

(defun my/exwm-switch-to-other-monitor (&optional dir)
  "Switch to another monitor."
  (interactive)
  (my/exwm-last-workspaces-clear)
  (exwm-workspace-switch
   (cl-loop with other-monitor = (my/exwm-get-other-monitor (or dir 'right))
            for i in (append my/exwm-last-workspaces
                             (cl-loop for i from 0
                                      for _ in exwm-workspace--list
                                      collect i))
            if (if other-monitor
                   (string-equal (plist-get exwm-randr-workspace-output-plist i)
                                 other-monitor)
                 (not (plist-get exwm-randr-workspace-output-plist i)))
            return i)))

I bind this function to s-q, as I’m used from i3.

Move the workspace to another monitor

Now, moving the workspace to another monitor.

This is actually quite easy to pull off - one just has to update exwm-randr-workspace-monitor-plist accordingly and run exwm-randr-refresh. I just add another check there because I don’t want some monitor to remain without workspaces at all.

(defun my/exwm-workspace-switch-monitor ()
  "Move the current workspace to another monitor."
  (interactive)
  (let ((new-monitor (my/exwm-get-other-monitor 'right))
        (current-monitor (my/exwm-get-current-monitor)))
    (when (and current-monitor
               (>= 1
                   (cl-loop for (key value) on exwm-randr-workspace-monitor-plist
                            by 'cddr
                            if (string-equal value current-monitor) sum 1)))
      (error "Can't remove the last workspace on the monitor!"))
    (setq exwm-randr-workspace-monitor-plist
          (map-delete exwm-randr-workspace-monitor-plist exwm-workspace-current-index))
    (when new-monitor
      (setq exwm-randr-workspace-monitor-plist
            (plist-put exwm-randr-workspace-monitor-plist
                       exwm-workspace-current-index
                       new-monitor))))
  (exwm-randr-refresh))

In my configuration this is bound to s-<tab>.

Windmove between monitors

And the final (for now) piece of the puzzle is using the same command to switch between windows and monitors. E.g. when the focus is on the right-most window on one monitor, I want the command to switch to the left-most window on the monitor to the right instead of saying “No window right from the selected window”, as windmove-right does.

So here is my implementation of that. It always does windmove-do-select-window for 'down and 'up. For 'right and 'left though, the function calls the previously defined function to switch to other monitor if windmove-find-other-window doesn’t return anything.

(defun my/exwm-windmove (dir)
  "Move to window or monitor in the direction DIR."
  (if (or (eq dir 'down) (eq dir 'up))
      (windmove-do-window-select dir)
    (let ((other-window (windmove-find-other-window dir))
          (other-monitor (my/exwm-get-other-monitor dir))
          (opposite-dir (pcase dir
                          ('left 'right)
                          ('right 'left))))
      (if other-window
          (windmove-do-window-select dir)
        (my/exwm-switch-to-other-monitor dir)
        (cl-loop while (windmove-find-other-window opposite-dir)
                 do (windmove-do-window-select opposite-dir))))))

I bind it to the corresponding keys like that:

(setq exwm-input-global-keys
      `(
        ...
        ;; Switch windows
        (,(kbd "s-<left>") . (lambda () (interactive) (my/exwm-windmove 'left)))
        (,(kbd "s-<right>") . (lambda () (interactive) (my/exwm-windmove 'right)))
        (,(kbd "s-<up>") . (lambda () (interactive) (my/exwm-windmove 'up)))
        (,(kbd "s-<down>") . (lambda () (interactive) (my/exwm-windmove 'down)))

        (,(kbd "s-h") . (lambda () (interactive) (my/exwm-windmove 'left)))
        (,(kbd "s-l") . (lambda () (interactive) (my/exwm-windmove 'right)))
        (,(kbd "s-k") . (lambda () (interactive) (my/exwm-windmove 'up)))
        (,(kbd "s-j") . (lambda () (interactive) (my/exwm-windmove 'down)))
        ...)

Managing windows

Another thing I want to tackle here is managing windows.

This section of the post depends on evil-mode, which provides a reasonable set of vim-like commands to manage windows. But a few points to improve upon remain.

Moving windows

As I wrote in my Emacs and i3 post, I want to have a rather specific behavior when moving windows (which does resemble i3 in some way):

  • if there is space in the required direction, move the Emacs window there;
  • if there is no space in the required direction, but space in two orthogonal directions, move the Emacs window so that there is no more space in the orthogonal directions;

I can’t say it’s better or worse than the built-in functionality or one provided by evil, but I’m used to it and I think it fits better for managing a lot of windows.

So, first, we need a predicate that checks whether there is space in the given direction:

(defun my/exwm-direction-exists-p (dir)
  "Check if there is space in the direction DIR.

Does not take the minibuffer into account."
  (cl-some (lambda (dir)
             (let ((win (windmove-find-other-window dir)))
               (and win (not (window-minibuffer-p win)))))
           (pcase dir
             ('width '(left right))
             ('height '(up down)))))

And a function to implement that:

(defun my/exwm-move-window (dir)
  "Move the current window in the direction DIR."
  (let ((other-window (windmove-find-other-window dir))
        (other-direction (my/exwm-direction-exists-p
                          (pcase dir
                            ('up 'width)
                            ('down 'width)
                            ('left 'height)
                            ('right 'height)))))
    (cond
     ((and other-window (not (window-minibuffer-p other-window)))
      (window-swap-states (selected-window) other-window))
     (other-direction
      (evil-move-window dir)))))

My preferred keybindings for this part are, of course, s-<H|J|K|L>:

(setq exwm-input-global-keys
      `(
        ;; Moving windows
        (,(kbd "s-H") . (lambda () (interactive) (my/exwm-move-window 'left)))
        (,(kbd "s-L") . (lambda () (interactive) (my/exwm-move-window 'right)))
        (,(kbd "s-K") . (lambda () (interactive) (my/exwm-move-window 'up)))
        (,(kbd "s-J") . (lambda () (interactive) (my/exwm-move-window 'down)))
        ...))

Resizing windows

I find this odd that there are different commands to resize tiling and floating windows.

So let’s define one command to perform both resizes depending on the context:

(setq my/exwm-resize-value 5)

(defun my/exwm-resize-window (dir kind &optional value)
  "Resize the current window in the direction DIR.

DIR is either 'height or 'width, KIND is either 'shrink or
 'grow.  VALUE is `my/exwm-resize-value' by default.

If the window is an EXWM floating window, execute the
corresponding command from the exwm-layout group, execute the
command from the evil-window group."
  (unless value
    (setq value my/exwm-resize-value))
  (let* ((is-exwm-floating
          (and (derived-mode-p 'exwm-mode)
               exwm--floating-frame))
         (func (if is-exwm-floating
                   (intern
                    (concat
                     "exwm-layout-"
                     (pcase kind ('shrink "shrink") ('grow "enlarge"))
                     "-window"
                     (pcase dir ('height "") ('width "-horizontally"))))
                 (intern
                  (concat
                   "evil-window"
                   (pcase kind ('shrink "-decrease-") ('grow "-increase-"))
                   (symbol-name dir))))))
    (when is-exwm-floating
      (setq value (* 5 value)))
    (funcall func value)))

This function will call exwm-layout-<shrink|grow>[-horizontally] for EXWM floating window and evil-window-<decrease|increase>-<width|height> otherwise.

This function can be bound to the required keybindings directly, but I prefer a hydra to emulate the i3 submode:

(defhydra my/exwm-resize-hydra (:color pink :hint nil :foreign-keys run)
  "
^Resize^
_l_: Increase width   _h_: Decrease width   _j_: Increase height   _k_: Decrease height

_=_: Balance          "
  ("h" (lambda () (interactive) (my/exwm-resize-window 'width 'shrink)))
  ("j" (lambda () (interactive) (my/exwm-resize-window 'height 'grow)))
  ("k" (lambda () (interactive) (my/exwm-resize-window 'height 'shrink)))
  ("l" (lambda () (interactive) (my/exwm-resize-window 'width 'grow)))
  ("=" balance-windows)
  ("q" nil "quit" :color blue))

Splitting windows

M-x evil-window-[v]split (bound to C-w v and C-w s by default) are the default evil command to do splits.

One EXWM-related issue though is that by default doing such a split “copies” the current buffer to the new window. But as EXWM buffer cannot be “copied” like that, some other buffer is displayed in the split, and generally, that’s not a buffer I want.

For instance, I prefer to have Chrome DevTools as a separate window. When I click “Inspect” on something, the DevTools window replaces my Ungoogled Chromium window. I press C-w v, and most often I have something like *scratch* buffer in the opened split instead of the previous Chromium window.

To implement better behavior, I define the following advice:

(defun my/exwm-fill-other-window (&rest _)
  "Open the most recently used buffer in the next window."
  (interactive)
  (when (and (eq major-mode 'exwm-mode) (not (eq (next-window) (get-buffer-window))))
    (let ((other-exwm-buffer
           (cl-loop with other-buffer = (persp-other-buffer)
                    for buf in (sort (persp-current-buffers) (lambda (a _) (eq a other-buffer)))
                    with current-buffer = (current-buffer)
                    when (and (not (eq current-buffer buf))
                              (buffer-live-p buf)
                              (not (string-match-p (persp--make-ignore-buffer-rx) (buffer-name buf)))
                              (not (get-buffer-window buf)))
                    return buf)))
      (when other-exwm-buffer
        (with-selected-window (next-window)
          (switch-to-buffer other-exwm-buffer))))))

This is meant to be called after doing an either vertical or horizontal split, so it’s advised like that:

(advice-add 'evil-window-split :after #'my/exwm-fill-other-window)
(advice-add 'evil-window-vsplit :after #'my/exwm-fill-other-window)

This works as follows. If the current buffer is an EXWM buffer and there are other windows open (that is, (next-window) is not the current window), the function tries to find another suitable buffer to be opened in the split. And that also takes the perspectives into account, so buffers are searched only within the current perspective, and the buffer returned by persp-other-buffer will be the top candidate.

Notes on floating windows

Floating windows are not the most stable feature of EXWM.

One story is that closing a floating window often screws up the current perspective, but that’s advised away by my perspective-exwm-mode.

Another is that these three settings (which are reasonably recommended in the Emacs Desktop series) seem to increase chances of breaking the current EXWM session:

(setq exwm-workspace-warp-cursor t)
(setq mouse-autoselect-window t)
(setq focus-follows-mouse t)

Occasionally they create a loop of mouse warps and focus changes. I found that disabling them just for the floating windows greatly stabilized that part:

(defun my/fix-exwm-floating-windows ()
  (setq-local exwm-workspace-warp-cursor nil)
  (setq-local mouse-autoselect-window nil)
  (setq-local focus-follows-mouse nil))

(add-hook 'exwm-floating-setup-hook #'my/fix-exwm-floating-windows)

However, one particularly unfriendly app is the Zoom app, which proudly creates a million various popups and still manages to break the EXWM sesssion. Fortunately, it can be used from a browser, which is what I advise to do.

What else not to do

A couple of final notes to make using EXWM a somewhat better experience.

First, this fix by David helped with one case of EXWM freezing, which I managed to get into a few times.

Second, do not run transients while there’s an active EXWM window in the workspace, especially if it’s it char-mode. That seems to break the session quite securely.

Third, running shutdown or something like that in the console is not the greatest idea, because things like kill-emacs-hook are not triggered in this case. For instance, EMMS history & elfeed databases are not saved.

P.S.

The way how characters aligned in my keybinding for EMMS is coincidental and does not carry any semantic value. The a is for “app”, s is because e and m were already taken by elfeed and notmuch, and the second s is because it’s faster to press the same character twice.