端末上の Vim 向けにテキストベースのスクロールバーを作った話
こんにちは、obcat です🐱
タイトルの通り、端末上の Vim 向けにテキストベースのスクロールバーを表示するプラグインを作りました。この記事では、このプラグインの機能やしくみをご紹介したいと思います。
👾 vim-sclow
vim-sclow は、ウィンドウの右端にスクロールバーを表示するプラグインです。スクロールバーはポップアップウィンドウを使って作りました。
GUI の Vim にはデフォルトの機能としてスクロールバーが搭載されていますが、端末上の Vim にはありません。「端末上の Vim 向け」と謳っているのはこのためで、vim-sclow は GUI の Vim でも動きます。
今のところ、スクロールバーはカレントウィンドウにしか表示されません。また、描画に関する深刻な問題を抱えています。詳しくは最後のセクションでご説明しますが、なぜかこの問題はウィンドウが二つ以上あるときには発生しないので、ファイルツリーを常時表示しているような方であれば常用していただけるかなと思います。
このように色々と問題がありますが、けっこう頑張って作ったのでぜひ使ってみてください。
🔥 Motivation
Vim には、目的の場所にジャンプできる機能がたくさんあります。たとえば、n
コマンドを使って次の検索ワードにジャンプしたり、gd
コマンドを使ってローカル変数の宣言箇所にジャンプしたりすることができます。
このようなジャンプ機能は便利な半面、軽い副作用をもっています。画面が 1 フレームで切り替わるので、自分がコードのどの辺りにいるのかが分からなくことがあるのです。
とくに n
コマンドは、 wrapscan
オプションを有効にしているときに逆方向にジャンプすることもあり、使っているとよく迷子になります。この問題を解決するために、逆方向にジャンプしたことをポップアップで知らせるプラグインを作ったりしてみたのですが、イマイチしっくりきませんでした。
そんなとき、Web ブラウザで単語を検索して結果を巡回するときは迷子にならないことに気がつきました。そして、ブラウザで迷子にならないのは、ブラウザにはスクロールバーがあるからだということに気がつきました。
スクロールバーは、画面をスクロールする機能のほかに、次の二つの重要な機能をもっているのです。
- スクロールバーの位置から、文書全体のどのあたりに位置しているかが直感的かつ瞬時に分かる
- スクロールバーの長さから、文書全体の長さが直感的かつ瞬時に分かる
とにかく、スクロールバーがあればすべてが解決すると気づきました。端末の Vim ではなく GUI の Vim を使えばよい話ではあるのですが、GUI の Vim のスクロールバーの見た目があまり好きではなかったので、vim-sclow を作ることにしました。
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]]
最初はグリッパーを一つのポップアップウィンドウとして実装していたのですが、グリッパーを水平方向に移動させるときに、鉛直方向の位置も同時に指定する必要があったりして大変でした😵
メイン処理
vim-sclow は、次のイベントを監視しています。
CursorMoved,CurdorMovedI,CursorHold,BufEnter
そして、これらのイベントが起こるたびに次の処理を行うようにしました。
- スクロールバーが存在しないとき、
- スクロールバーを作成する。
- スクロールバーが存在するとき、
また、BufLeave,WinLeave
のタイミングでスクロールバーが存在するならば削除する、という処理も行っているため、カレントウィンドウにのみスクロールバーが表示されるようになっています。なお、カレントウィンドウに限定したのは実装をシンプルにするためです。いい方法を思いついたら、すべてのウィンドウで表示されるようにアップデートするかもしれません。
長さの計算
グリッパーと上下のオフセットの長さは、次の図で示されているように計算しました。
(line('w0') - 1
、winheight(0)
、line('$') - line('w$')
でとれます)
ここで注意しなければならないのは、ポップアップマスクの範囲の指定には整数しか使えないということです。たとえば、ベースの上端から 2.5 行分だけ透明にする、といったことはできません。そのため、上の図の
また、スクロールバーは次の3条件をみたすべきだと私は考えました。
- グリッパーの長さ
はスクロールしても一定で、正の整数h - 上オフセット
は、ファイルの先頭行が見えているときはt 、そうでないときは正の整数0 - 下オフセット
は、ファイルの最終行が見えているときはb 、そうでないときは正の整数0
すべての条件をみたす方法がすぐには分からず、とても悩みましたが、gcavallanti/vim-noscrollbar の実装をパクって事なきを得ました😆
長さの計算をする関数の実際のコード
" 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
さいごに、冒頭でふれた描画に関するバグについて説明したいと思います。
ウィンドウが一つしかない場合、スクロールすると、このように画面全体が激しくチラつきます。私の環境(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 のソースを直すことはできません。つよいエンジニアの方が直してくれるのを指をくわえて待っています。
Discussion