alligator's blog

Creating your own TUI in Vim

Oct 14, 2021

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:

Prerequisites

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.

What are these boxes?

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.

Setup

Create a file called slog.vim somewhere and start writing. Nothing else to it.

Creating the entry-point

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.

Creating the 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.

Adding content

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.

Opening a commit

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
Why is 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.

Going back to the commit list

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
boolean values

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.

Syntax highlighting

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:

Wrapping it up

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()

blog index