Permanently display workspaces *in the tab-bar*

We have Permanently display workspaces in minibuffer already, but we can also hijack the tab-bar to have a truly permanent display of workspaces without encumbering the minibuffer. Note that this removes any display of Emacs’ native tabs. Personally, I find them unnecessary given that we have workspaces already.

(after! persp-mode
  ;; alternative, non-fancy version which only centers the output of +workspace--tabline
  (defun workspaces-formatted ()
    (+doom-dashboard--center (frame-width) (+workspace--tabline)))

  (defun hy/invisible-current-workspace ()
    "The tab bar doesn't update when only faces change (i.e. the
current workspace), so we invisibly print the current workspace
name as well to trigger updates"
    (propertize (safe-persp-name (get-current-persp)) 'invisible t))

  (customize-set-variable 'tab-bar-format '(workspaces-formatted tab-bar-format-align-right hy/invisible-current-workspace))

  ;; don't show current workspaces when we switch, since we always see them
  (advice-add #'+workspace/display :override #'ignore)
  ;; same for renaming and deleting (and saving, but oh well)
  (advice-add #'+workspace-message :override #'ignore))

;; need to run this later for it to not break frame size for some reason
(run-at-time nil nil (cmd! (tab-bar-mode +1)))

We can also get a bit fancier and change faces and the workspace format, yielding a tab-bar like this:

(custom-set-faces!
  '(+workspace-tab-face :inherit default :family "Jost" :height 135)
  '(+workspace-tab-selected-face :inherit (highlight +workspace-tab-face)))

(after! persp-mode
  (defun workspaces-formatted ()
    ;; fancy version as in screenshot
    (+doom-dashboard--center (frame-width)
                             (let ((names (or persp-names-cache nil))
                                   (current-name (safe-persp-name (get-current-persp))))
                               (mapconcat
                                #'identity
                                (cl-loop for name in names
                                         for i to (length names)
                                         collect
                                         (concat (propertize (format " %d" (1+ i)) 'face
                                                             `(:inherit ,(if (equal current-name name)
                                                                             '+workspace-tab-selected-face
                                                                           '+workspace-tab-face)
                                                               :weight bold))
                                                 (propertize (format " %s " name) 'face
                                                             (if (equal current-name name)
                                                                 '+workspace-tab-selected-face
                                                               '+workspace-tab-face))))
                                " "))))
;; other persp-mode related configuration
)

1 Like

This looks good, adapted for my private config & using perspectives.el rather than persp-mode (extremely minimal changes)

(declare-function "persp-names" 'perspective)

(defgroup lkn-tab-bar nil
  "Options related to my custom tab-bar."
  :group 'tab-bar)

(defgroup lkn-tab-bar-faces nil
  "Faces for the tab-bar."
  :group 'lkn-tab-bar)

(defface lkn-tab-bar-workspace-tab
  '((t :inherit default))
  "Face for a workspace tab."
  :group 'lkn-tab-bar-faces)

(defface lkn-tab-bar-selected-workspace-tab
  '((t :inherit (highlight lkn-tab-bar-workspace-tab)))
  "Face for a selected workspace tab."
  :group 'lkn-tab-bar-faces)

(defun lkn-tab-bar--workspaces ()
  "Return a list of the current workspaces."
  (let ((persps (persp-names))
	(persp (persp-current-name)))
    (seq-reduce
     (lambda (acc elm)
       (let ((face (if (equal persp elm)
		       'lkn-tab-bar-selected-workspace-tab
		     'lkn-tab-bar-workspace-tab)))
	 (concat
	  acc
	  (concat
	   (propertize (format " %d" (1+ (cl-position elm persps)))
		       'face
		       `(:inherit ,face
			 :weight bold))
	   (propertize (format " %s " elm)
		       'face `,face)))))
     persps
     `(,(propertize (persp-current-name) 'invisible t))))))

(customize-set-variable 'global-mode-string '((:eval (lkn-tab-bar--workspaces)) " "))
(customize-set-variable 'tab-bar-format '(tab-bar-format-global))
(customize-set-variable 'tab-bar-mode t)

This is lovely, and I have been using it for a while. One issue: while I don’t usually use the mouse for anything, it’s occasionally a nice option for a quick, arbitrary navigation action like “click on a tab bar tab” (especially when I’m using a mac, with their lovely trackpads), and unlike the default tab bar contents, neither of these tab bar definitions do anything when a tab is clicked.

Unfortunately, the relevant APIs and their documentation feel pretty opaque to me, so I haven’t been able to sort this out in the time that I am willing to allocate for such a niche (to me) workflow. But it does seem like a pretty central tab bar use case, so worth calling out (plus, there’s a small chance some kind soul has already figured this out). Meanwhile, I’ll keep poking at it occasionally; here’s hoping I find my way to some small breakthrough.

Saw this very late (notifications eh…) but I have a solution for this. An incomplete solution but one nonetheless.

So the TL;DR here is tab-bar-mode functions slightly different to how other things work. It’s only expected to work with tabs, so all the click logic is hard-coded to be for tabs.

In order to combat this, we have to write a generic handler (which to me screams for a defgeneric solution) to handle the “click event” on whatever you clicked. Using the below snippet like emacs -Q -l tabs.el, you get another item in the tab-bar which when clicked, gives a different message in the modeline vs clicking the tab

(defun test-tab-click-handler (evt)
  "Function to handle clicks on the custom tab."
  (interactive "e")
  (message "Hi" evt))

(keymap-set tab-bar-map "<mouse-1>" 'test-tab-click-handler)

(defun test-tab-item ()
  (propertize "Click Me"
              'mouse-face 'highlight))

(setq tab-bar-format (cons 'test-tab-item tab-bar-format))

(tab-bar-mode 1)

The same would also have to be done for the other click events and things like touchscreens. I’ll reply back here when I have a complete snippet, but if either of you do so in the meantime feel free to do the same :smile:

Okay, we got there…

To save me constantly updating this, the config can be found here

Pasting the whole module config to show how I did it, but because each of these “tabs” are rendered as a string, we have to compute the pixel width of each tab and set that as a property on the tab.

Once we have that, the click handler becomes a simple case of given the x position of the click; filter out any “tabs” that have a right edge less than that and we take the car of that. This gives us the tab we clicked on; or nil if we clicked elsewhere (thanks to when-let* we get no-op for free).

For the other click events, you would look at tab-bar--event-to-item to figure out which click event it was then dispatch with pcase/cond/etc

1 Like

This is nice, but for me it seems to show the workspaces tabs in the company popup. Does this happen for anyone else?

Upgrading to emacs 29.4 has fixed this for me.