Using the tab-bar in Emacs

emacs geekery

When I’m working in Emacs, I like to have some visual separation between different workspaces (which roughly equate to different projects). Previously, I was using Doom Emacs’ workspaces feature, which uses persp-mode. My initial reason for disabling workspaces was because I found that projectile-switch-project didn’t work properly with it enabled, but I also thought it would be interesting to see what I could set up using built-in Emacs functions. What I ended up using was Emacs’ built-in tab-bar.

Emacs (at least in version 27.1) has two kinds of tabs: the tab-line feature is similar to the way tabs are handled in most other editors. Each window (which in other editors would be considered a pane inside a window) has a row of tabs at the top to open buffers which have been associated with that window. Tab-bar, on the other hand, is associated with frames (which would be considered windows in other editors), and switches between different window configurations (or layouts of buffers in split windows) in one frame. I hadn’t thought about using tab-bar before, because I assumed (wrongly) that you had to have the graphical tabs visible at the top of the frame, and I didn’t like the visual noise that involved. However — as with all things in Emacs — it turned out that this is completely configurable. If you don’t like the visible tabs, you don’t have to have them: you use (tab-bar-show nil) to turn them off, and the commands and key-bindings work just the same without them.

I wanted to use tabs to visually separate different projects which I might need open at the same time, each of which is usually a separate projectile project. You can name tabs and then switch between projects by name in the minibuffer, using whatever completion mechanism you usually use. I thought it would be handy to name tabs automatically with the projectile project name, and save myself from having to do it manually. I dug around in the documentation for the functions related to tab-bars and found that you could set a variable to the name of a function to do the naming of tabs. I could then write my own function to get the project name and set that as the tab name, or revert to a number if we’re not in a project: (projectile-project-name) returns “-” in the latter case. This really only required looking at the built-in tab naming function, copying it and modifying it in a minor way to do what I wanted (which is 90% of my emacs-lisp hacking, to be honest). While I was at it, I also re-used the SPC TAB leader key formerly used by the workspaces function to hold some useful tab-bar commands. This is how I have set it up:

(defun my/name-tab-by-project-or-default ()
  "Return project name if in a project, or default tab-bar name if not.
The default tab-bar name uses the buffer name."
  (let ((project-name (projectile-project-name)))
    (if (string= "-" project-name)
        (tab-bar-tab-name-current)
      (projectile-project-name))))

(setq tab-bar-mode t)
(setq tab-bar-show nil)
(setq tab-bar-new-tab-choice "*doom*")
(setq tab-bar-tab-name-function #'my/name-tab-by-project-or-default)

(map! :leader
      (:prefix-map ("TAB" . "Tabs")
       :desc "Switch tab" "TAB" #'tab-bar-select-tab-by-name
       :desc "New tab" "n" #'tab-bar-new-tab
       :desc "Rename tab" "r" #'tab-bar-rename-tab
       :desc "Rename tab by name" "R" #'tab-bar-rename-tab-by-name
       :desc "Close tab" "d" #'tab-bar-close-tab
       :desc "Close tab by name" "D" #'tab-bar-close-tab-by-name
       :desc "Close other tabs" "1" #'tab-bar-close-other-tabs))

My workflow is that when I want to work on a different project, I hit SPC TAB n to create a new tab, which brings me to the Doom dashboard, because I set that as the default tab-bar-new-tab-choice above. Then I use SPC pp to switch project and choose my project. The wrinkle I hit here was that while my function had named the tab appropriately (which I could see if I used the command to select a tab by name), it didn’t appear in the modeline. If I set the name manually, it would appear in the modeline.

After more digging in the naming functions, I realised that names set manually (as opposed to programmatically) are designated as the tab’s ‘explicit name’: Doom modeline checks for the explicit name, otherwise it just shows the tab number. There were a number of different ways I could see to fix this, but in the end I decided on the least disruptive way I could think of, which was to re-define the relevant segment of Doom modeline so that it always shows the name of the tab, whether explicitly set or not. The code below does that in a slightly clunky way by setting both tab-name and explicit-name to current-tab, but it works!

(after! doom-modeline
  (doom-modeline-def-segment workspace-name
  "The current workspace name or number.
Requires `eyebrowse-mode' or `tab-bar-mode' to be enabled."
  (when doom-modeline-workspace-name
    (when-let
        ((name (cond
                ((and (bound-and-true-p eyebrowse-mode)
                      (< 1 (length (eyebrowse--get 'window-configs))))
                 (assq-delete-all 'eyebrowse-mode mode-line-misc-info)
                 (when-let*
                     ((num (eyebrowse--get 'current-slot))
                      (tag (nth 2 (assoc num (eyebrowse--get 'window-configs)))))
                   (if (< 0 (length tag)) tag (int-to-string num))))
                (t
                 (let* ((current-tab (tab-bar--current-tab))
                        (tab-index (tab-bar--current-tab-index))
                        (explicit-name (alist-get 'name current-tab))
                        (tab-name (alist-get 'name current-tab)))
                   (if explicit-name tab-name (+ 1 tab-index)))))))
      (propertize (format " %s " name) 'face
                  (if (doom-modeline--active)
                      'doom-modeline-buffer-major-mode
                    'mode-line-inactive))))))

The final bit of customisation I did was to set a keybinding for the two keys on my ErgoDox which are on the inner edges of each half on the top row. I have set these to switch to the previous buffer (left half) or next buffer (right half), so it seemed natural to go to the previous/next tab by holding CTRL and hitting the key.

I’m really pleased with this new set up. It works well for my use, and the only thing I am missing from my former workspaces set up is isolation of each project within each workspace, so that only buffers in the project are available. However, now that I am used to the Emacs way of doing things (have hundreds of open buffers, and use the completion framework to quickly narrow to what you want), this doesn’t bother me. If I specifically want to search for files or buffers in a project, there are projectile commands to do that.

Emacs may be a dangerous rabbit hole at times, but I can’t see myself ever going back to an editor which is not so incredibly open to inspection of the code and customisation. It’s amazing that you can so easily look up the code that defines built-in functions and then adapt it yourself (while the editor is running!).