sqrtminusone.github.io/org/2022-01-03-exwm.org

501 lines
26 KiB
Org Mode

#+HUGO_SECTION: posts
#+HUGO_BASE_DIR: ../
#+TITLE: Using EXWM and perspective.el on multi-monitor setup
#+DATE: 2022-01-03
#+HUGO_TAGS: emacs
#+HUGO_TAGS: exwm
#+HUGO_DRAFT: false
I wrote about [[https://sqrtminusone.xyz/posts/2021-10-04-emacs-i3/][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 "[[https://systemcrafters.net/emacs-desktop-environment/][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 [[https://github.com/daviwil/emacs-from-scratch/blob/master/Desktop.org][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
[[https://github.com/nex3/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 [[https://github.com/Alexander-Miller/treemacs][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 [[https://github.com/SqrtMinusOne/perspective-exwm.el][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:
#+begin_src emacs-lisp
(use-package perspective-exwm
:straight t
:config
...)
#+end_src
Then load the provided minor mode before =exwm-init=:
#+begin_src emacs-lisp
(use-package exwm
:config
...
(perspective-exwm-mode)
(exwm-init))
#+end_src
** 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:
#+begin_src emacs-lisp
(setq perspective-exwm-override-initial-name
'((0 . "misc")
(1 . "core")
(2 . "browser")
(3 . "comms")
(4 . "dev")))
#+end_src
** 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:
#+begin_src emacs-lisp
(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)
#+end_src
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 [[https://github.com/noctuid/general.el][general.el]], I made a macro to automate that:
#+begin_src emacs-lisp
(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))
#+end_src
=fboundp= is meant to provide compatibility with running Emacs without EXWM. Usage of the macro is as follows:
#+begin_src emacs-lisp
(my-leader-def
:infix "as"
"" '(:which-key "emms")
"s" (my/command-in-persp "emms" "EMMS" 0 (emms-smart-browse))
...)
#+end_src
=my-leader-def= is a [[https://github.com/noctuid/general.el#creating-new-key-definers][custom definer]]. That way the defined keybinding opens [[https://www.gnu.org/software/emms/][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 [[https://www.gnu.org/software/emms/][EMMS]] and [[https://github.com/skeeto/elfeed][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-.=:
#+begin_src emacs-lisp :tangle no :noweb-ref exwm-keybindings
(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)
...)
#+end_src
* Workspaces on multiple monitors
Here, =exwm-randr= provides basic functionality for running EXWM on multiple monitors. For instance, with configuration like that:
#+begin_src emacs-lisp :eval no
(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)
#+end_src
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:
- a command to [[https://i3wm.org/docs/userguide.html#_focusing_moving_containers][switch to another monitor]];
- a command to [[https://i3wm.org/docs/userguide.html#move_to_outputs][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:
#+begin_src emacs-lisp
(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)
#+end_src
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:
#+begin_src emacs-lisp
(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)))
#+end_src
** 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:
#+begin_src emacs-lisp :eval no
(setq my/exwm-monitor-list
(pcase (system-name)
("indigo" '(nil "DVI-D-0"))
(_ '(nil))))
#+end_src
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:
#+begin_src emacs-lisp :eval no
(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)))
#+end_src
And a function to cycle the monitor list in either direction:
#+begin_src emacs-lisp
(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))
#+end_src
** 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.
#+begin_export html
<video controls width="100%">
<source src="/videos/exwm-workspace-switch.mp4" type="video/mp4">
</video>
#+end_export
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.
#+begin_src emacs-lisp
(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)))
#+end_src
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.
#+begin_export html
<video controls width="100%">
<source src="/videos/exwm-workspace-move.mp4" type="video/mp4">
</video>
#+end_export
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.
#+begin_src emacs-lisp
(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))
#+end_src
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.
#+begin_src emacs-lisp
(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))))))
#+end_src
I bind it to the corresponding keys like that:
#+begin_src emacs-lisp
(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)))
...)
#+end_src
* Managing windows
Another thing I want to tackle here is managing windows.
This section of the post depends on [[https://github.com/emacs-evil/evil][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 [[https://sqrtminusone.xyz/posts/2021-10-04-emacs-i3/][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:
#+begin_src emacs-lisp
(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)))))
#+end_src
And a function to implement that:
#+begin_src emacs-lisp
(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)))))
#+end_src
My preferred keybindings for this part are, of course, =s-<H|J|K|L>=:
#+begin_src emacs-lisp
(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)))
...))
#+end_src
** Resizing windows
I find this odd that there are different commands to resize tiling and floating windows.
#+begin_export html
<video controls width="100%">
<source src="/videos/exwm-resize-hydra.mp4" type="video/mp4">
</video>
#+end_export
So let's define one command to perform both resizes depending on the context:
#+begin_src emacs-lisp
(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)))
#+end_src
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:
#+begin_src emacs-lisp
(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))
#+end_src
** 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:
#+begin_src emacs-lisp
(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))))))
#+end_src
This is meant to be called after doing an either vertical or horizontal split, so it's advised like that:
#+begin_src emacs-lisp
(advice-add 'evil-window-split :after #'my/exwm-fill-other-window)
(advice-add 'evil-window-vsplit :after #'my/exwm-fill-other-window)
#+end_src
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 [[https://github.com/daviwil/emacs-from-scratch/blob/5ebd390119a48cac6258843c7d5e570f4591fdd4/show-notes/Emacs-Desktop-04.org#mouse-warping][recommended]] in the Emacs Desktop series) seem to increase chances of breaking the current EXWM session:
#+begin_src emacs-lisp
(setq exwm-workspace-warp-cursor t)
(setq mouse-autoselect-window t)
(setq focus-follows-mouse t)
#+end_src
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:
#+begin_src emacs-lisp
(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)
#+end_src
However, one particularly unfriendly app is the [[https://zoom.us/][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, [[https://github.com/daviwil/exwm/commit/7b1be884124711af0a02eac740bdb69446bc54cc][this fix]] by David helped with [[https://github.com/ch11ng/exwm/issues/842][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.