Using the tab-bar in Emacs
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!).