端末上の Vim 向けにテキストベースのスクロールバーを作った話

8 min read読了の目安(約7300字

この記事は、Vim 2 Advent Calendar 2020 の 2 日目の記事です(遅れましたごめんなさい)

こんにちは、obcat です🐱

タイトルの通り、端末上の Vim 向けにテキストベースのスクロールバーを表示するプラグインを作りました。この記事では、このプラグインの機能やしくみをご紹介したいと思います。

👾 vim-sclow

sclow eyecatch

https://github.com/obcat/vim-sclow

vim-sclow は、ウィンドウの右端にスクロールバーを表示するプラグインです。スクロールバーはポップアップウィンドウを使って作りました。

GUI の Vim にはデフォルトの機能としてスクロールバーが搭載されていますが、端末上の Vim にはありません。「端末上の Vim 向け」と謳っているのはこのためで、vim-sclow は GUI の Vim でも動きます。

今のところ、スクロールバーはカレントウィンドウにしか表示されません。また、描画に関する深刻な問題を抱えています。詳しくは最後のセクションでご説明しますが、なぜかこの問題はウィンドウが二つ以上あるときには発生しないので、ファイルツリーを常時表示しているような方であれば常用していただけるかなと思います。

このように色々と問題がありますが、けっこう頑張って作ったのでぜひ使ってみてください。

今のところ、Neovim はサポートしていません🤦

🔥 Motivation

Vim には、目的の場所にジャンプできる機能がたくさんあります。たとえば、n コマンドを使って次の検索ワードにジャンプしたり、gd コマンドを使ってローカル変数の宣言箇所にジャンプしたりすることができます。

このようなジャンプ機能は便利な半面、軽い副作用をもっています。画面が 1 フレームで切り替わるので、自分がコードのどの辺りにいるのかが分からなくことがあるのです。

とくに n コマンドは、 wrapscan オプションを有効にしているときに逆方向にジャンプすることもあり、使っているとよく迷子になります。この問題を解決するために、逆方向にジャンプしたことをポップアップで知らせるプラグインを作ったりしてみたのですが、イマイチしっくりきませんでした。
vnn

https://github.com/obcat/vnn.vim

そんなとき、Web ブラウザで単語を検索して結果を巡回するときは迷子にならないことに気がつきました。そして、ブラウザで迷子にならないのは、ブラウザにはスクロールバーがあるからだということに気がつきました。

スクロールバーは、画面をスクロールする機能のほかに、次の二つの重要な機能をもっているのです。

  1. スクロールバーの位置から、文書全体のどのあたりに位置しているかが直感的かつ瞬時に分かる
  2. スクロールバーの長さから、文書全体の長さが直感的かつ瞬時に分かる

とにかく、スクロールバーがあればすべてが解決すると気づきました。端末の Vim ではなく GUI の Vim を使えばよい話ではあるのですが、GUI の Vim のスクロールバーの見た目があまり好きではなかったので、vim-sclow を作ることにしました。

GUI Vim
MacVim

(とくに、スクロールバーのベースがウィンドウよりも長いところが好きじゃない・・)

🤔 しくみ

ここからは、vim-sclow のしくみを簡単にご説明していきたいと思います。はじめに、以下で用いることばの定義をしておきましょう。

  • マウスで掴んでグリグリできることが期待される部分をグリッパーとよびます[1]
  • ウィンドウ上端からグリッパー上端までの部分を上オフセットとよびます。
  • ウィンドウ下端からグリッパー下端までの部分を下オフセットとよびます。
  • これらの全体をベースまたはスクロールバーとよびます。

用語の説明

実装のポイントは、(グリッパーではなく)ベースを一つのポップアップウィンドウにしたことです。そのうえで、ポップアップマスクという機能を使って、上下のオフセット部分を透明にするようにしました。

ポップアップマスク					*popup-mask*

ポップアップがカバーするテキストを最小化するために、その一部を透明にすることが
できる。これは、リストのリストである "mask" によって定義される。各リストには4
つの数値を持つ:
    col		開始桁、左からカウントする場合は正、1 は左端、右からカウントす
		る場合は負値、-1 は右端
    endcol	最終桁、"col" に似ている
    line	開始行、上からカウントする場合は正、1 は上端、下からカウントす
		る場合は負値、-1 は下端
    endline	最終行、"line" に似ている

例えば、最後の行の最後の10桁を透明にするには:
	[[-10, -1, -1, -1]]

4隅を透明にするには:
	[[1, 1, 1, 1], [-1, -1, 1, 1], [1, 1, -1, -1], [-1, -1, -1, -1]]

https://vim-jp.org/vimdoc-ja/popup.html#popup-mask

最初はグリッパーを一つのポップアップウィンドウとして実装していたのですが、グリッパーを水平方向に移動させるときに、鉛直方向の位置も同時に指定する必要があったりして大変でした😵

メイン処理

vim-sclow は、次のイベントを監視しています。

CursorMoved,CurdorMovedI,CursorHold,BufEnter

そして、これらのイベントが起こるたびに次の処理を行うようにしました。

  • スクロールバーが存在しないとき、
    • スクロールバーを作成する。
  • スクロールバーが存在するとき、
    • ウィンドウの右境界線が移動していたら[2]、ベースを水平方向に移動する。
    • ウィンドウの高さが変更されていたら[3]、ベースの高さを更新する。
    • スクロールされていたら[4]、ポップアップマスクを変更することでグリッパーの位置を更新する。

また、BufLeave,WinLeave のタイミングでスクロールバーが存在するならば削除する、という処理も行っているため、カレントウィンドウにのみスクロールバーが表示されるようになっています。なお、カレントウィンドウに限定したのは実装をシンプルにするためです。いい方法を思いついたら、すべてのウィンドウで表示されるようにアップデートするかもしれません。

長さの計算

グリッパーと上下のオフセットの長さは、次の図で示されているように計算しました。

scaling

Tline('w0') - 1H (=s) は winheight(0)Bline('$') - line('w$') でとれます)

ここで注意しなければならないのは、ポップアップマスクの範囲の指定には整数しか使えないということです。たとえば、ベースの上端から 2.5 行分だけ透明にする、といったことはできません。そのため、上の図の t, h, b の値はいずれも整数として決定する必要があります。

また、スクロールバーは次の3条件をみたすべきだと私は考えました。

  1. グリッパーの長さ h はスクロールしても一定で、正の整数
  2. 上オフセット t は、ファイルの先頭行が見えているときは 0、そうでないときは正の整数
  3. 下オフセット b は、ファイルの最終行が見えているときは 0、そうでないときは正の整数

すべての条件をみたす方法がすぐには分からず、とても悩みましたが、gcavallanti/vim-noscrollbar の実装をパクって事なきを得ました😆

長さの計算をする関数の実際のコード

https://github.com/obcat/vim-sclow/blob/9d65b1daf6aef507179ced4538ff851d24de7833/autoload/sclow.vim#L191

" Return popup masks. See `:h |popup-mask|`.
" +--------------------+
" |                    | <--+               <---+
" |                    |    | Mask top (ptop)   |
" |                    | <--+                   |
" |                   || <--+                   |
" |                   ||    | Gripper (height)  | Base (total)
" |       window      || <--+                   |
" |                    | <--+                   |
" |                    |    |                   |
" |                    |    | Mask bot (pbot)   |
" |                    |    |                   |
" |                    | <--+               <---+
" +--------------------+
" NOTE: ptop and pbot stand for padding top and padding bottom respectively.
"
" Requirements:
"   * Gripper length is not 0.
"   * Gripper length is constant.
"   * Padding top is not 0 if buffer's first line is not in the window.
"   * Padding bottom is not 0 if buffer's last line is not in the window.
function! s:get_masks(winheight, lnums) abort "{{{
  let PTOP   = a:lnums.w0 - 1          " = line('w0') - 1
  let HEIGHT = a:winheight             " = winheight(0)
  let PBOT   = a:lnums.S - a:lnums.wS  " = line('$') - line('w$')
  let total = HEIGHT

  if HEIGHT <= 2
    " Cannot meet all requirements. Mask all.
    return [s:mask(total, 'top')]
  endif

  if PTOP && PBOT "{{{
    let TOTAL = PTOP + HEIGHT + PBOT
    let scale = 1.0 * total / TOTAL
    let ptop   = float2nr(PTOP * scale)
    let height = float2nr(ceil(HEIGHT * scale))

    if !ptop
      let ptop = 1
      if ptop + height == total + 1
        let height -= 1
      endif
    endif
    if ptop + height == total
      let ptop -= 1
    endif

    let pbot = total - (ptop + height)

    return [
      \ s:mask(ptop, 'top'),
      \ s:mask(pbot, 'bot'),
      \ ]
  endif "}}}


  if PBOT "{{{
    let TOTAL = HEIGHT + PBOT
    let scale = 1.0 * total / TOTAL
    let ptop   = float2nr(PTOP * scale)
    let height = float2nr(ceil(HEIGHT * scale))
    let pbot   = total - (ptop + height)

    if !pbot
      let pbot = 1
    endif

    return [s:mask(pbot, 'bot')]
  endif "}}}


  if PTOP "{{{
    let TOTAL = PTOP + s:bufheight()
    let scale = 1.0 * total / TOTAL
    let ptop = float2nr(PTOP * scale)

    if !ptop
      let ptop = 1
    endif

    return [s:mask(ptop, 'top')]
  endif "}}}


  return s:hide_full_length
    \ ? [s:mask(total, 'top')]
    \ : []
endfunction "}}}

🐛 Critical bug

さいごに、冒頭でふれた描画に関するバグについて説明したいと思います。

Image from Gyazo

ウィンドウが一つしかない場合、スクロールすると、このように画面全体が激しくチラつきます。私の環境(Vim on (iTerm|terminal.app) on macOS)でしか確認できていませんが、この問題はポップアップウィンドウがあると必ず発生するので、Vim 自体のバグではないかと思っています。

https://github.com/vim/vim/issues/4458 で Bram さん(Vim の開発者🐵)が

when there is a popup all text is redrawn every time. It's part of the remaining work.

と仰っているように、ポップアップウィンドウには色々と問題が残っているみたいです。私は C 言語に明るくないので Vim のソースを直すことはできません。つよいエンジニアの方が直してくれるのを指をくわえて待っています。

脚注
  1. 残念ながら期待には応えられません。 ↩︎

  2. ベースがウィンドウの右端にあるかどうかで判断 ↩︎

  3. 保存しておいたウィンドウの高さを現在のものと比較して判断 ↩︎

  4. 保存しておいた行情報を現在のものと比較して判断 ↩︎