Emacs workspaces

I use Emacs for everything that can be done with it reasonably. I often run in a single Emacs session Gnus, Org agendas, command line actions, IRC, several development projects, etc. The question is how to switch between all those activities efficiently.

During the years, I tried several workspace, perspective or screen (whatever they are called) Emacs add-on packages. But I have never been completely satisfied with any of them. There have always been bugs, limitations and/or discomfort.

Then I applied the principle of simplicity: Use the simplest thing that may work. And the thing is Emacs frames. As simple as that, I’ve been using my workspaces based on Emacs frames for almost two years now and it has been working to my satisfaction so far.

The frames provide two crucial facilities:

  • Separate dynamic window configurations and switching between them easily. Frames provide that by definition. The only problem is that your window manager must be able to manage a lot of Emacs frames reasonably. But any civilized window manager should be able to do that and e.g. KDE is.
  • Switching to workspace specific buffers. Frames do that by default — at least with Ido, recently used buffers in the given frame are offered first when switching buffers. Unlike in some other workspace solutions, the buffers offered are not limited just to the given workspace, which is a killer feature. It’s much easier not to care about assignments of buffers to workspaces and to simply rely on the recent buffers approach.

Of course, C-x 5 key bindings are not enough to work with workspaces efficiently. I implemented my own workspace management mechanism, based on my wrappers around the previous solutions I used and described below.

First, it’s useful to track the last frame because returning to the last workspace is a very common action:

(defvar my-last-frame nil)

Let’s track last workspaces too:

(defvar my-last-workspaces '())

Frame switching may not work smoothly in some window environments, so let’s make a custom function to select a frame and to store the workspace to the variables defined above:

(defun my-select-frame (frame)
  (unless (eq frame (selected-frame))
    (setq my-last-frame (selected-frame)))
  (raise-frame frame)
  (x-focus-frame frame)
  (select-frame frame)
  (setq my-last-workspaces (cons frame (remove frame my-last-workspaces))))

I give my workspaces names, stored and displayed in frame titles. Let’s define functions retrieving workspace names:

(defun my-workspace-name (&optional frame)
  (let ((frame (or frame (selected-frame))))
    (or (frame-parameter frame 'title)
        (frame-parameter frame 'name))))

(defun my-all-workspace-names ()
  (setq my-last-workspaces (cl-delete-if-not #'frame-live-p my-last-workspaces))
  (mapcar 'my-workspace-name
          (delete-dups (append my-last-workspaces (frame-list)))))

Now we can switch among workspaces:

(defun my-workspace-switch (name)
  (interactive "sWorkspace: ")
  (let ((frame-list (frame-list))
        (frame nil)
        (new nil))
    (while (and (not frame) frame-list)
      (if (string= (my-workspace-name (car frame-list)) name)
          (setq frame (car frame-list))
        (setq frame-list (cdr frame-list))))
    (unless frame
      (setq frame (make-frame `((title . ,name))))
      (setq new t))
    (my-select-frame frame)

(defun my-last-workspace ()
  (if (and my-last-frame (frame-live-p my-last-frame))
      (my-select-frame my-last-frame)
    (my-workspace-switch (read-string "Switch to workspace: "))))

As you can see, it’s possible to switch to an already present workspace or to a new workspace, using the same command. But typing the workspace name each time is cumbersome, let’s make it easier:

(defun my-switch-to-workspace (&optional key)
  (unless key
    (setq key (logand last-command-event 255)))
  (let* ((prefix (char-to-string key))
         (current-name (my-workspace-name))
         ;; This will be explained later:
         (predefined (cl-assoc prefix my-workspaces :test #'string-prefix-p)))
    (if predefined
        (cl-destructuring-bind (name new-fn refresh-fn) predefined
          (if (equal current-name name)
              (when refresh-fn
                (funcall refresh-fn))
            (when (and (my-workspace-switch name)
              (funcall new-fn))))
      (let* ((candidates
              (remove current-name
                       #'(lambda (n) (string-prefix-p prefix n))
             (n (length candidates)))
         ((= n 0)
           (read-from-minibuffer "Switch to workspace: ")))
         ((= n 1)
           (car candidates)))
           (completing-read "Switch to workspace: " candidates nil t prefix))))))))

(global-set-key (kbd "<s-return>") 'my-last-workspace)
(dotimes (i 26)
  (global-set-key (kbd (format "s-%c" (+ ?a i))) 'my-switch-to-workspace))

This implements a single key workspace switching and assigns it to key bindings. I use a–z letters, as seen above, together with Super modifier to switch between workspaces. I name my workspaces in such a way that the name of each of them preferably starts with a different letter, making this mechanism work very well. I also define C-c z binding to be able to switch among workspaces when Super key happens not working:

(defun my-switch-to-workspace-key (key)
  (interactive "c")
  (my-switch-to-workspace key))  
(global-set-key (kbd "C-c z") 'my-switch-to-workspace-key)

As there are Emacs applications I run often, in their own workspaces, it’s useful to have predefined workspaces:

(defvar my-workspaces
  ;; name
  ;; new-fn
  ;; refresh-fn
     (lambda ()
       (require 'bookmark)
       (unless bookmark-alist
       (bookmark-jump "init.d"))) 
     (lambda ()
       (let ((group-buffer (get-buffer "*Group*")))
         (if group-buffer
             (switch-to-buffer group-buffer)
           (cd "~")))))
     (lambda ()
        (lambda ()
          (ido-buffer-internal ido-default-buffer-method nil nil nil "eshell:")))))))

Each entry in this list defines a workspace name, the function to run when the workspace is created and the function to run when switching to the workspace I’m already in (a “refresh” function), which is especially useful to reset the workspace to its default state. The corresponding part in my-switch-to-workspace above takes care of the predefined workspaces.

One last bit is handling workspaces containing development projects. It’s natural to name them after their source directories but that could quickly lead to initial letter collisions with other workspaces. I dedicated a special x: prefix to those workspaces. I use Projectile to manage my source directories and integrate it with my workspaces:

(defconst my-projectile-workspace-prefix "x:")

(defun my-projectile-switch-project-action ()
  (when (my-workspace-switch (concat my-projectile-workspace-prefix
(setq projectile-switch-project-action 'my-projectile-switch-project-action)  

When I open a development project using Projectile, it automatically switches to its workspace, names it accordingly and opens Magit for it. Then I can switch to the project using s-x key. If I work on multiple development projects simultaneously, my-switch-to-workspace offers the last one used by default to avoid using completion all the time.

As you can see, utilizing Emacs frames as workspaces is easy and powerful. I don’t need to use or make special add-ons to have workspaces, Emacs frames are good enough and they can be customized as needed relatively easily.






One response to “Emacs workspaces”

  1. […] used to use Emacs frames as workspaces. But reading through Emacs 28 NEWS reminded me that tab bars could be probably better used for the […]

Leave a Reply

Your email address will not be published. Required fields are marked *