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 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
(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
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-]. For switching perspectives, I’ve bound
(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
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
However, some features, common in other tiling WMs, are missing in EXWM out of the box, namely:
- a command to switch to another monitor;
- a command to move the current workspace to another monitor;
- using the same commands to switch between windows and monitors.
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)
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
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
So here is my implementation of that. It always does
'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))) ...)
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.
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,
(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))) ...))
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
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))
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
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.
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.
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.
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
m were already taken by elfeed and notmuch, and the second
s is because it’s faster to press the same character twice.