Creating a TUI in Vim isn't hard, but isn't well documented. After poring over some popular plugins I think I know how to do it. Once you read this, I hope you will too.
We're going to create a git log viewer called slog
. When a user runs the command :Slog
, a window will appear showing a list of commits for the git repository in the current directory. Pressing enter on a commit will show the message and diff for that commit. Pressing enter again will return to the list of commits. Pressing q
will close the window.
It looks like this:
I assume you know Vim quite well, and you know some vimscript, the sort of stuff you'd write in a vimrc. I also assume you know how to use Vim's help, but I'll tell you the help command you need when it's not obvious.
An exceedingly useful help page is :h vim-functions
, an easy to scan list of Vim's built in functions.
To keep this article skim-friendly, I'll put things you might like to know but don't need to know in these boxes. The title will tell you what's explained within.
Create a file called slog.vim
somewhere and start writing. Nothing else to it.
We start with a function that will be our entry-point and a command, Slog
, that calls it.
" the s: makes this function local to this script " see :h s:var function! s:slog() echo 'hello, world' endfunction command! -nargs=0 Slog call s:slog()
To test it, source the file with :source %
and run :Slog
. You should see hello, world
at the bottom of your window.
Next we add a function that creates a window.
function! s:create_window() " create a new window on the right that's 80 columns wide vertical botright 80new " stop the user from editing the buffer setlocal nomodifiable " tell Vim this is a temporary buffer not backed by a file setlocal buftype=nofile bufhidden=wipe noswapfile " no line numbers, no wrapping, highlight the current line setlocal nonumber cursorline nowrap nospell " set the file name of the buffer file [slog] endfunction
The set of options starting with buftype
make this a “special buffer”—a buffer that shows something other than a file (see :h special-buffers
.)
We call this in s:slog
:
function! s:slog() call s:create_window() endfunction
When you run the command, you should see a nice new empty window to the right of your current one.
To display the list of commits, we'll have git do the heavy lifting. This function displays the output of git log
and a title:
function! s:show_commits() " let us modify the buffer setlocal modifiable " write the title call append(0, 'Slog') " write the log messages silent! read !git log -100 --oneline " move the cursor to the first message call cursor(3, 0) setlocal nomodifiable endfunction
silent!
silent!
is used to hide the X more lines
message you see after a :read
command.
It goes below create_window
in s:slog
.
function! s:slog() call s:create_window() call s:show_commits() endfunction
If you :cd
to a git repository and run the command, you should see a list of commits in the new window.
To make our commit list interactive, we add some buffer-local mappings:
function! s:add_mappings() nmap <silent> <buffer> q :bd<CR> nmap <silent> <buffer> <Enter> :call <SID>handle_enter()<CR> endfunction
<SID>
The <SID>
in the second mapping is how we call a script-local function from outside a script. When the user presses enter and this mapping runs, our script will have long since finished, so s:
won't work.
Script local variables and functions can still be used outside of a script, but their names are mangled to stop clashes. For example, in my current Vim session our s:create_window
function is actually named <SNR>108_create_window()
. You can see what script functions you have by entering :call <SNR>
then pressing tab to see the available options.
Vim replaces <SID>
in the mapping with the mangled prefix, so it can still find the function after the script has finished.
See :h <SID>
.
The first makes q
close the buffer. The second makes pressing enter call a function we haven't defined yet, handle_enter
.
Here it is:
function! s:handle_enter() setlocal modifiable " get the commit hash by splitting the current line on spaces let hash = split(getline('.'))[0] " clear the buffer using the black-hole register " see :h "_ silent! normal! gg"_dG " show the commit execute 'silent read !git show ' . hash " move the cursor to the top call cursor(1, 0) " take advantage of vim's built in git syntax highlighting setfiletype git setlocal nomodifiable endfunction
execute
used?
We need to pass our hash to git show
, but everything after the ! in read !
is passed as-is to the shell. We work around this by make the command a string, concatenating the hash on to it, and using execute
to, uh, execute it.
This gets the commit hash from the current line and uses git show
to show information about commit in the buffer.
It joins the crowd in s:slog
.
function! s:slog() call s:create_window() call s:add_mappings() call s:show_commits() endfunction
Now pressing enter on a commit should show it's message and diff, and q should close the window. Pressing enter while looking at a commit causes an error. Let's fix that by going back to the list of commits instead.
We can store whether or not we're looking at a commit in a variable. This is declared at the top of create_window
:
function! s:create_window() let s:viewing_commit = v:false ... endfunction
Before Vim 8 vimscript had no boolean values, 0 was false and anything else was true. Vim 8 added a boolean type and the special variables v:false
and v:true
.
If you want your script to work in Vim 7, use 0 and 1 instead of v:false
and v:true
.
We check this in handle_enter
. If it's true we show the list of commits, if it's false we show a single commit.
function! s:handle_enter() setlocal modifiable if s:viewing_commit " show the list of commits silent! normal! gg"_dG call s:show_commits() let s:viewing_commit = v:false else " show a single commit (same code as before) " get the commit hash let hash = split(getline('.'))[0] " clear the buffer using the black-hole register silent! normal! gg"_dG " show the commit execute 'silent read !git show ' . hash " move the cursor to the top call cursor(1, 0) " take advantage of vim's built in git syntax highlighting setfiletype git let s:viewing_commit = v:true endif setlocal nomodifiable endfunction
You should now return to the list of commits if you press enter while looking at a commit.
We can add some colour to the list of commits by declaring our own syntax highlighting for the buffer. This function will highlight the title and the commit hashes:
function! s:add_syntax() " clear any highlighting from the buffer syn clear " match the first line of the buffer " see :h /\%l syn region slogTitle start=/\%1l/ end=/\%2l/ " match a commit hash syn match slogCommit /[a-f0-9]\{7}/ " link the groups created above to some built-in ones hi def link slogTitle Title hi def link slogCommit Constant endfunction
It's added to the bottom of show_commits
:
function! s:show_commits() ... call s:add_syntax() endfunction
Looking at examples is the best way to see what can done with syntax highlighting, here's the syntax in a couple of popular plugins:
That's it! Now you know how to show your own stuff in a Vim buffer and run things when someone interacts with it.
Here are the plugins I looked at a lot while writing this:
And Steve Losh's Learn Vimscript the Hard Way was usually open somewhere. If you haven't read it, you should.
Here's the full code for slog.vim
:
function! s:create_window() let s:viewing_commit = v:false " create a new window on the right that's 80 columns wide vertical botright 80new setlocal nomodifiable " tell vim this is a temporary buffer not backed by a file setlocal buftype=nofile bufhidden=wipe noswapfile " no line numbers, no wrapping, highlight the current line setlocal nonumber cursorline nowrap nospell " set the current file name file [slog] endfunction function! s:add_mappings() nmap <silent> <buffer> q :bd<CR> nmap <silent> <buffer> <Enter> :call <SID>handle_enter()<CR> endfunction function! s:add_syntax() syn clear syn region slogTitle start=/\%1l/ end=/\%2l/ syn match slogCommit /[a-f0-9]\{7}/ hi def link slogTitle Title hi def link slogCommit Constant endfunction function! s:show_commits() setlocal modifiable " write the title call append(0, 'Slog') " write the log messages silent! read !git log -100 --oneline " move the cursor to the first message call cursor(3, 0) setlocal nomodifiable call s:add_syntax() endfunction function! s:handle_enter() setlocal modifiable if s:viewing_commit " clear the buffer using the black-hole register silent! normal! gg"_dG call s:show_commits() let s:viewing_commit = v:false else " get the commit hash let hash = split(getline('.'))[0] " clear the buffer using the black-hole register silent! normal! gg"_dG " show the commit execute 'silent read !git show ' . hash " move the cursor to the top call cursor(1, 0) " take advantage of vim's built in git syntax highlighting setfiletype git let s:viewing_commit = v:true endif setlocal nomodifiable endfunction function! s:slog() call s:create_window() call s:add_mappings() call s:show_commits() endfunction command! -nargs=0 Slog call s:slog()