Vim Tip #30: Numbering Your Tabs

2020-06-16(Tue)

tags: Vim

Vim Tips

[This is an UPDATE of a previous post. I've almost entirely rewritten the code: this version should be preferred.]

Vim likes to think of files as "buffers," but I prefer them as "tabs." To use Vim, it's really important to understand the distinction even if you prefer tabs, and if you don't ... stay away from this tip.

I use tabs a lot in Vim, and the fastest way to get around is the Normal mode command <n>gt, where <n> is a number, and gt means "go tab". gt on its own - without a number - simply moves to the next tab. But if you have several tabs open, typing it over and over becomes annoying and using tab numbers becomes desirable. But if you have four or more tabs open, looking at the Tabline and figuring out the number of the tab you're headed for takes a few seconds - it takes you out of the flow of your work. I figured there must be a way to add tab numbers to the Tabline. There absolutely is, but things turn ugly quite quickly. If you read :help setting-tabline, the documentation will set you on the path - even including example code. Unfortunately the example code is quite rudimentary. It even says "You will want to reduce the width of labels in a clever way when there is not enough room." So ... you gave us example code, but it's actually less functional than the default Vim Tabline? Yes, yes it is. Searching online shows that I'm far from the only person to have had this idea. Unfortunately, I tested a couple of the proffered code blocks and found that most of them weren't, as the documentation put it, "clever." If you open a lot of tabs, the Tabline becomes too long to see all of it ... and if you can't see all of it, it's not very useful is it?

I decided to try to fix this myself. Is what I've created good? Hell no - but I'm pleased with it because I set out a few days ago with no damn idea if I could do this at all, and it does what I wanted. And I hope to improve it further.

What it does:

  • shows the filename devoid of path in each tab
  • shows a pipe (as a separator) and a tab number at the right of each tab
  • if the tab contains more than one window, shows a window count and a pipe as a separator on the left of the tab (if you have more than 9 windows, it will just show "9")
  • if the contents of any window in the tab are modified, shows the tab number brightly coloured
  • if the focussed window in a tab is a help file, removes the ".txt" extension and prefaces the name with "h:"
  • uses a simplistic shortening scheme to reduce the length of filenames when there's a lot of tabs, truncating from the middle and adding an unusual character (I chose "◊", but there are many options shown below and it should be easy for you to change in the code) to indicate truncation. This truncation code is not well tested, and may get ugly when tab widths drop below 10 characters.
  • partially overrides the :colorscheme colours on the tabline, although it does offer some choices. Only the "MTL" colours are well tested, the support for standard :colorscheme less well ...

Here's the current code. To use this, copy this code into ~/.vim/plugin/Tabline.vim and it will be automatically loaded the next time you start Vim. It's just as easily removed: either delete it or change the extension to anything not .vim. Or you can comment out the set tabline=%!MyTabLine() line and turn it on and off at will as noted in the code.

" Code initially borrowed from :help setting-tabline.  Hugely changed since.
" Turn on:
"   :set tabline=%!MTL()  aka "My Tab Line"
" Turn off (revert to Vim's standard behaviour):
"   :set tabline=

" Choose your colour scheme - the choices are:
" - "vim" - use the colorscheme Vim is using
" - "airline" - match airline's default colours always, overriding colorscheme
" - "MTL" - use MTL's default colours for the Tabline always, overriding colorscheme
"       (these colours are mostly the same as Vim's "default" colorscheme)
let g:MTLcolourScheme = 'MTL'

scriptencoding utf-8

" Activate!:
set tabline=%!MTL()

function MTLsetTabColours()
    if g:MTLcolourScheme ==? 'MTL'
        hi TabLine     cterm=underline ctermfg=15 ctermbg=242 gui=underline guibg=DarkGrey
        hi TabLineSel  cterm=bold      ctermfg=15  ctermbg=0   gui=bold      guibg=DarkGrey
        hi TabLineFill cterm=reverse   ctermfg=15 ctermbg=0   gui=bold      guibg=Black
        " New colour for modified tabs:
        hi MTLcolourModified  ctermfg=15 ctermbg=9 guifg=White guibg=Red
    elseif g:MTLcolourScheme ==? 'airline'
        call MTLcopyHighlight('airline_x_inactive', 'Tabline')
        call MTLcopyHighlight('airline_term',       'TabLineSel')
        hi MTLcolourModified   cterm=none   ctermfg=15  ctermbg=90    guifg=White   guibg=Red
    elseif g:MTLcolourScheme ==? 'vim'
        call MTLcopyHighlight('WarningMsg', 'MTLcolourModified')
    else
        call MTLcopyHighlight('WarningMsg', 'MTLcolourModified')
    endif
endfunction

function! MTLcopyHighlight(group, newgroup)
    " Given a highlight group, copy its settings to highlight group "newgroup"
    let l:output = execute('highlight ' . a:group)
    " the leading '.' in the following group is to match a rather tricky
    " '^@'/Ctrl-@ character that appears in the output and otherwise really
    " messes things up.
    let l:input  = substitute(l:output, '.' . a:group, a:newgroup, '')
    let l:input  = substitute(l:input,  'xxx', '', '')
    " set our new highlight group to the same settings:
    execute 'hi ' . l:input
endfunction

function MTLisBufferModified(bufferNo)
    if getbufvar( a:bufferNo, '&modified' )
        return 1
    else
        return 0
    endif
endfunction

function MTLisTabModified(tabNo)
    let buflist = tabpagebuflist(a:tabNo)
    let l:modifiedCount = 0
    for b in buflist
        if MTLisBufferModified(b)
            let l:modifiedCount += 1
        endif
    endfor
    if l:modifiedCount > 0
        return 1
    else
        return 0
    endif
endfunction

function MTLshortenFilename(filename, length, backOffset, truncSymbol)
    let l:shortfilename = matchstr(a:filename, '[^/]*$')
    if len(l:shortfilename) > a:length
        let l:end = strcharpart(l:shortfilename, strlen(l:shortfilename) - a:backOffset)
        let l:begin = strcharpart(l:shortfilename, 0, strlen(l:shortfilename) - a:backOffset)
        let l:chopCount = len(l:shortfilename) - a:length
        return
            \ strcharpart(l:begin, 0, len(l:begin) - l:chopCount)
            \ . a:truncSymbol
            \ . l:end
    else
        return l:shortfilename
    endif
endfunction

function MTLlabel(tabNo, twidth, selected)
    " Takes the tab number, the width available, and whether or not the tab is
    " selected.
    let l:tabContent = ''
    " lists are zero-indexed, throw a dummy in at the beginning ...
    let l:winCountIndicator = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']
    " a list of all the buffer numbers in the current tab:
    let buflist = tabpagebuflist(a:tabNo)
    let winnr = tabpagewinnr(a:tabNo)
    let l:filename = bufname(buflist[winnr - 1])
    " Get just the filename, no path:
    let l:shortfilename = matchstr(l:filename, '[^/]*$')
    " set a starting colour for our tab:
    if a:selected
        let l:tabContent .= '%#TabLineSel#'
    else
        let l:tabContent .= '%#TabLine#'
    endif
    let l:wincount = len(buflist)
    if l:wincount == 1
        "do nothing
    elseif l:wincount < 10
        let l:tabContent .= l:winCountIndicator[l:wincount] . '|'
    else
        let l:tabContent .= l:winCountIndicator[9] . '|'
    endif
    " modify the display title if it's a help buffer
    if getbufvar( buflist[winnr - 1], '&buftype' ) ==# 'help'
        let l:shortfilename = 'h:' . substitute(l:shortfilename, '.txt$', '', '')
    endif
    " modify the display title if it's a terminal buffer
    if getbufvar( buflist[winnr - 1], '&buftype' ) ==# 'terminal'
        let l:shortfilename = 't:' . substitute(l:shortfilename, '.*[0-9][0-9]*:', '', '')
    endif

    " Final trim:
    " we need a special character when a filename is
    " shortened: preferably something quickly visually identifiable as NOT
    " in your standard character set and thus extremely unlikely to
    " actually be part of a filename.  My choices:
    "   ∥ ‖ ∷ ∓ ≈ ⌒ ∙ ⊙ ⊚ ● ⊥ ┅ ⑀ □ ▢ ▣ ◆ ◇ ◊
    "   Look good but double-wide: 〄 《 》〜
    let l:shortfilename = MTLshortenFilename(l:shortfilename, a:twidth, 4, '◊')

    let l:tabContent .= l:shortfilename
    let l:tabContent .= '|'
    if MTLisTabModified(a:tabNo)
        let l:tabContent .= '%#MTLcolourModified#'
    endif
    let l:tabContent .= a:tabNo

    return l:tabContent
endfunction

function MTL()
    " force my choice of tab highlight colours, overriding colorscheme:
    call MTLsetTabColours()
    " The simplistic calculation of tab width: &columns / tabpagenr('$')
    " But we need to do a bit more math ...
    "   - 2 white spaces, one at either end of every tab
    "   - 2 spaces for each tab's number (pipe separator and one digit)
    "   - 2 spaces for number and separator IF more than one window
    " Suddenly I'm seeing why Vim keeps so much spare space on the tabline ...
    let l:tabTitleWidth = ( &columns / tabpagenr('$')) - 6

    let l:finalTabline = ''
    for t in range(tabpagenr('$'))
        " select the highlighting
        if t + 1 ==? tabpagenr()
            let l:finalTabline .= '%#TabLineSel#'
        else
            let l:finalTabline .= '%#TabLine#'
        endif

        " set the tab page number (for mouse clicks)
        let l:finalTabline .= '%' . (t + 1) . 'T'

        " the label is made by MTLlabel()
        let l:finalTabline .= ' '
        let l:finalTabline .= MTLlabel(t + 1, l:tabTitleWidth, (t + 1 ==+ tabpagenr() ) )
        let l:finalTabline .= ' '

    endfor

    " after the last tab fill with TabLineFill and reset tab page nr
    let l:finalTabline .= '%#TabLineFill#%T'

    " right-align the label to close the current tab page
    if tabpagenr('$') > 1
        let l:finalTabline .= '%=%#TabLine#%999XX'
    endif

    return l:finalTabline
endfunction

What's wrong with it:

  • the tab width calculation sucks, and is going to break down when tab width gets too low (below seven characters?)
  • the truncation code is way too simplistic
  • there may be issues with mouse support as I almost never use or test it
  • there are thousands of circumstances under which this has NOT been tested
  • colour behaviour is likely to be problematic as I don't fully understand it

What I'd like it to do:

  • better handling of in-tab terminals
  • if there's a lot of tab space, provide partial paths as well as filename
  • if there are more tabs than space and the Tabline is longer than the terminal width, centre the current tab ... the documentation's demonstration implementation forces the first tab (or several tabs) off screen to the left, and if you do 1gt you're jumping to a tab that's never displayed (the file(s) are, but not the associated tab). The Tabline should be shifted so that you see the tab you're on).