Andrey Listopadov

Making Emacs tabs work like in Atom

Another little piece from my Emacs config that I’ve decided to turn into a small post, following up on previous one. This time, we’re going to make tabs work as in most graphical editors.

Tabs were added with global-tab-line-mode in Emacs 27, and are pretty simple tabs, that are being displayed on the top of a window, and by default, their semantics are not very useful in my opinion. The main problem is that you get only two policies of how the closing of a tab will behave. It is either bury-buffer, which simply hides the tab, and moves the buffer to the end of the buffer list, and kill-buffer which always kills the buffer, and hides the tab, and you can’t really combine those behaviors by default.

This is not very useful, because you can have many windows, with the same buffers in each one, and when you want to close the window you can’t do that by closing the last tab. For example, in Atom, if we have such configuration:

If we close file-B tab we will be left with one window:

This is useful and works the same way in VSCode or Sublime Text. Let’s try the same thing in emacs -q. First, we create two windows, with a tab in each:

Then we press little close button on file-b tab:

And it replaces it with the file-A tab. This happens because when we click on the close button it calls bury-buffer, so the tab goes away, a new buffer is displayed, and we’re getting a new tab for it. This is not great. I think when we close the last tab in the window, the window should be killed as in Atom1.

The other thing is this. In Atom we can have two windows with the same buffers opened like this:

And if you close one of the tabs the file will not be closed until all tabs are closed, but the window will be:

In Emacs, however, we can’t close the tab via the close button, because it will cycle buffers, so instead we can switch to the second policy of killing buffers instead. But it will not work either, because it will not kill the window, and it will kill the buffer in all windows. So how do we fix this?

First, let’s establish some logical rules:

  • Buffer can exist without a tab, but a tab can not exist without a buffer,
  • If the tab was closed buffer should be killed only if there are no tabs for this buffer left,
  • If the tab was closed and there are no more tabs in the window, the window should be killed.

These three rules are basically how tabs work in modern editors. Now we need to implement this in Emacs Lisp. Since there’s no way of configuring which function is used for closing a tab, we’re going to override it with an advice in our configuration file:

(define-advice tab-line-close-tab (:override (&optional e))
  "Close the selected tab.
If the tab is presented in another window, close the tab by using the `bury-buffer` function.
If the tab is unique to all existing windows, kill the buffer with the `kill-buffer` function.
Lastly, if no tabs are left in the window, it is deleted with the `delete-window` function."
  (interactive "e")
  (let* ((posnp (event-start e))
         (window (posn-window posnp))
         (buffer (get-pos-property 1 'tab (car (posn-string posnp)))))
    (with-selected-window window
      (let ((tab-list (tab-line-tabs-window-buffers))
            (buffer-list (flatten-list
                          (seq-reduce (lambda (list window)
                                        (select-window window t)
                                        (cons (tab-line-tabs-window-buffers) list))
                                      (window-list) nil))))
        (select-window window)
        (if (> (seq-count (lambda (b) (eq b buffer)) buffer-list) 1)
              (if (eq buffer (current-buffer))
                (set-window-prev-buffers window (assq-delete-all buffer (window-prev-buffers)))
                (set-window-next-buffers window (delq buffer (window-next-buffers))))
              (unless (cdr tab-list)
                (ignore-errors (delete-window window))))
          (and (kill-buffer buffer)
               (unless (cdr tab-list)
                 (ignore-errors (delete-window window)))))))

Now let’s break this down. First, we’re storing our posnp, window, and buffer, then with our selected window, we get a list of tabs via a call to tab-line-tabs-window-buffers, which effectively returns a list of buffers that have tabs associated with those. Then we get a full list of buffers in all windows and store it to buffer-list variable, which we will need later.

Next, we select the window and count how many times we find buffers in our list of buffers from all windows. If it is more than 1, then we can we’re going to bury that buffer. If it is 1 we’re going to kill it since it is the last tab of that buffer.

There’s another check in there, that checks if the tab is last in the list of tabs for that window, and if it is, e.g. if (cdr tab-list) returned nil we’ve just closed the last tab in the window, so we can kill it as well.

In the end, we force a mode-line update because it updates the tab line as well. So now, with this function (and the rest of my config) let’s try previous examples in Emacs:

Now we close file-B:

Buffer file-B and its respective window both were killed. Now we create windows with the same buffer:

And if we close the right tab, we see that window is deleted, but the buffer is not affected:

And if we had tab file-A in first window and two tabs for file-A and file-B in second, closing file-A in second window will not kill file-A buffer, and will not kill the window, because tab for file-B is still there:

Closing file-A tab doesn’t kill file-A buffer or window:

Closing file-B tab kills its buffer and window:

This makes tabs in Emacs behave as in Atom or VSCode.

  1. Atom actually has a setting for it, so when you close the last tab it can display an empty window, in which you can later drag and drop new tabs, but in Emacs, there is no such thing as empty windows, so it makes sense to always kill the window if there are no tabs left. ↩︎