The stuff I do

Highlighting yanked text with pure vimscript

← Posts

A few days ago I saw a blog post showing a built-in way to highlight yanked text on neovim. The author uses neovim's lua integration combined with the :h TextYankPost autocommand event:

augroup highlightYankedText
autocmd!
autocmd TextYankPost * silent! lua require'vim.highlight'.on_yank()
augroup END

I liked the idea of having visual feedback on my yanks but I am still reluctant to use the lua integration. Despite all the bad things you can read about vimscript online I like to keep my configuration in pure vimscript as much as possible mostly for portability reasons.

So I decided to reinvent the wheel replicate this behavior with pure vimscript! This was interesting to do because it involves playing with patterns and matches which are the basic building bricks of Vim's highlighting function. And because these are not tools that I need to use regularly so I am not very familiar with them.

So here is the result I am looking for: In this screen cap you can see me first yanking the word "filetype" with yiw, then the full line with yy and finally several lines:

Demo of highlighted text

In this article I will to relate the main steps I followed to get this feature working. To keep it readable I will not follow all the best practices or go into all the details to make it flawless but I hope this kind of iteration process can help new vimmers to get into vimscript by demonstrating some useful concepts.

A reminder on how to highlight stuff

First a bit of Vim terminology about highlighting:

Making the pattern work

The first step to highlight yanked text is to be able to match the last yanked text. Fortunately, :h '[ tells us that Vim has two marks '[ and '] which are positioned on the first and last characters of the previously changed or yanked text.

And :h /\%'m tells us that we can use such marks in a pattern, so my first attempt looked like this:

let g:idTemporaryHighlight = matchadd('IncSearch', "\\%'\\[.*\\%']")

The main items of the pattern are the following:

This is a great first attempt which kind of works on some simple cases but fails when yanking text on several lines. This is because the . atom doesn't match end of lines characters, so we need to use \_. instead:

let g:idTemporaryHighlight = matchadd('IncSearch', "\\%'\\[\\_.*\\%']")

That is better but still not completely working, for example the first and last characters of the yanked text are not highlighted. That's the moment where we turn to the doc and read a bit more what :h /\%'m has to say, particularly this:

Example, to highlight the text from mark 's to 'e:
	/.\%>'s.*\%<'e..
Note that two dots are required to include mark 'e in the match.  That
is because "\%<'e" matches at the character before the 'e mark, and
since it's a |/zero-width| match it doesn't include that character.

Easy peasy, let's reuse the same thing but with our marks '[ and ']:

let g:idTemporaryHighlight = matchadd('IncSearch', ".\\%>'\\[\\_.*\\%<']..")

Important note: Some of this escaping could be greatly simplified using a different magic mode but it's not my point here. For more information on magic in Vim see :h /magic

So after a few tests yanking some random text, using matchadd to highlight it and matchdelete to remove the highlighting I am satisfied with the result, it is then time to automatically highlight our text.

Using the pattern automatically

Vim provides since it patch 8.0.1394 the :h TextYankPost autocommand event which triggers just after a yank or deleting command. So our first step is to create a function triggered by this event:

augroup highlightYankedText
autocmd!
autocmd TextYankPost * call FlashYankedText()
augroup END

function! FlashYankedText()
let g:idTemporaryHighlight = matchadd('IncSearch', ".\\%>'\\[\\_.*\\%<']..")
endfunction

As you can see I put my autocommand in an augroup because every time you use an autocommand without an augroup you make a kitten cry 😿... and for other reasons too.

That is great! Each time we yank some text it gets highlighted... but then it remains highlighted indefinitely. So let's simply use a timer to delete the match we just created. Note that the function puts the id of the newly created match in a global variable which is kind of ugly but pretty pratical to access it in the DeleteTemporaryMatch() function.

function! FlashYankedText()
let g:idTemporaryHighlight = matchadd('IncSearch', ".\\%>'\\[\\_.*\\%<']..")
call timer_start(500, 'DeleteTemporaryMatch')
endfunction

function! DeleteTemporaryMatch(timerId)
call matchdelete(g:idTemporaryHighlight)
endfunction

Making the feature reliable

The previous code kind of works but some edge cases are problematic:

So let's put the ids in a list, with the window id where they were created:

function! FlashYankedText()
if (!exists('g:yankedTextMatches'))
let g:yankedTextMatches = []
endif

let matchId = matchadd('IncSearch', ".\\%>'\\[\\_.*\\%<']..")
let windowId = winnr()

call add(g:yankedTextMatches, [windowId, matchId])
call timer_start(500, 'DeleteTemporaryMatch')
endfunction

Now DeleteTemporaryMatch() can simply dequeue the g:yankedTextMatches list and remove the matches on the corresponding window:

function! DeleteTemporaryMatch(timerId)
while !empty(g:yankedTextMatches)
let match = remove(g:yankedTextMatches, 0)
let windowID = match[0]
let matchID = match[1]

try
call matchdelete(matchID, windowID)
endtry
endwhile
endfunction

For good measures the call to matchdelete() is enclosed in a try...catch block, just in case something else fails and I don't want to be bothered with an error message.

And here we are! With about 20 lines of vimscript we reimplemented the highlight yanked text feature! Well, kind of, there are some edge cases which needs a bit more work for this to work flawlessly, especially the cases where the user changes of window before DeleteTemporaryMatch() is called.

Turning it into a plugin

Now that we have a code working properly, we could leave that in our .vimrc and live happily with that... But it would be even better to make it a plugin! This way the functions will be loaded only when necessary (and thus, avoid increasing your startup time), we can get rid of global variables and just have a clean line in our .vimrc, and while we are at it we could create a variable to control how long the flash should last... And that's actually what I did!

I think the specific of how I turned my code into a plugin would make this post way too long, so the result can be found on my github and I am of course available to answer any questions you could have about it.

← Posts


Comments