🌈

俺自身がVimのstatuslineになることだ

2024/01/17に公開

この記事はVim駅伝の2024-01-17の記事です。
前回の記事はyasunoriさんのVimプラグインマネージャー『dpp.vim』への移行と設定方針です。
次回の記事はtakeさんのEmacsユーザーから見たvim-jpです。


🏳‍🌈( '‿' )

https://github.com/kawarimidoll/kawarimiline.vim

このプラグインは画像表示のデモ的な意図で作成しました。興味のある方はコードを見てみてください。

もちろん、こちらのリスペクトです。nya!

https://github.com/TeMPOraL/nyan-mode

ここからは作成時に得た知見の共有記事となります。

Vimで画像表示する方法

ターミナルで画像を表示するために必要なライブラリがlibsixelです。

https://github.com/saitoha/libsixel

そして、これを利用するコマンドには以下のようなものがあります。ターミナルアプリケーション自体がsixelに対応していれば、画像を表示できます。

しかし、VimもNeovimも、内蔵ターミナルがsixelに対応していないため、これらを:terminalで使用してもきれいに画像を表示できません。


:terminal では色付き四角形での表示になってしまう

ただ、画像表示プラグインには先例があるので、何らかの方法があるはずとは思っていました。

今回kawarimilineを作る上で直接参考になったのは、Vimではこのリポジトリ

https://github.com/mattn/vim-nyancat

およびこのツイート

https://twitter.com/mattn_jp/status/1229054480785608707

Neovimではこのissueでした。

https://github.com/neovim/neovim/issues/23870

画像表示関数の例

結論としては、Vimではechoraw、Neovimではchansendを使ってエスケープシーケンスを直接出力します。それをターミナルが解釈できれば画像が表示されます。

単純な画像表示関数を以下に示します。

display_sixel.vim
let s:echoraw = has('nvim')
      \ ? {str->chansend(v:stderr, str)}
      \ : {str->echoraw(str)}

function s:display_sixel(path, lnum, cnum) abort
  " save cursor pos
  call s:echoraw("\x1b[s")

  " move cursor pos
  call s:echoraw($"\x1b[{a:lnum};{a:cnum}H")

  " display sixels
  call s:echoraw(system($"img2sixel {a:path}"))

  " restore cursor pos
  call s:echoraw("\x1b[u")
endfunction

call s:display_sixel('/path/to/image.png', 5, 10)

kawarimilineでは、さらに以下のような改良を加えて使用しています。

  • 毎回img2sixelを呼び出すのではなく、出力結果をキャッシュする
  • サイズ調整の引数を渡す
  • 複数画像を表示する場合は、すべての画像を表示し終えてからカーソル位置を復帰する

なお、display_sixelの引数のlnumcnumはターミナルのスクリーン座標であり、Vimの文字位置とは異なります。
たとえば「3行目の下に左寄せで画像を表示させたい」と思っても、4, 1を指定するとは限りません。windowの分割状況、tablineやsigncolumnの有無などで指定する座標が変わります。

制限

Vim自身に画像を扱うAPIがあるわけではないので、難しいポイントがけっこうあります。

すべてのターミナルで動くわけではない

そもそも、sixelを表示できるターミナルが限られています。

冒頭の画像はweztermで試したものです。また、iTerm2でも表示できました。
対して、alacrittyは記事執筆時点で未対応kittyは独自プロトコルのため、sixelを用いた表示はできません。kittyでの画像表示に詳しい方はPRを出していただけると嬉しいです。

サイズ調整が難しい

画像をsixelに変換した後にサイズの調整はできません。変換時にimg2sixelの引数で調整する必要があります。
また、ターミナルの行に合わせて表示するためには、画像の縦サイズを行の高さ(フォントサイズ+余白)の倍数にしたいところですが、この値を取得する一般的な方法はありません。各ターミナルの設定ファイルや設定画面にしかない情報なので、kawarimilineではユーザーに手動で設定してもらうようにしています。

不意に消去されることがある

sixelの出力は前述のs:echorawを実行したときにのみされているので、その後で画面の再描画が発生すると画像が消えます。
また、画像の下に隠れている文字やハイライトが変更された場合、画像が一部欠けてしまいます。この文字がambiwidthの対象である場合、見た目上では画像に重なっていなくても影響が出ることがあります。

任意に消去するのも難しい

execute "normal! \<c-l>"で画面の再描画を発生させればsixel画像を能動的に消去することができます。
しかし、複数の画像を表示させている場合、全て消えてしまうので、「画像を1つだけ消したい」というような場合は「すべて消して、それ以外を表示し直す」という対応が必要です。
「消したい画像の範囲のみ、ハイライトを更新する」という手もありそうですが、対象画像の表示領域を特定するのもかなり面倒だと思います。

透過処理は無理そう

img2sixelには背景色のオプションがあるので、ここにVimの背景色と同じ色を設定することで、透過を表現できる…と思いましたが、手許の環境では確認できませんでした。
また、これはあくまで背景色の設定なので、画像の下の文字を透過させたり、背景画像の上に物体の画像を載せたりすることはできないと思われます。

カーソルがちらつくことがある

前掲の関数の通り、画面内の任意の場所に画像を表示するためには、そこまでカーソルを移動させる必要があります。
これを繰り返すと、更新の間隔によっては、カーソルが激しくちらつく場合があります。

スクロールすると表示が崩れることがある

ステータスライン以外に画像を載せる場合、スクロールされることも想定しなければなりません。

いちおう、画像はスクロールに合わせて移動します。
ただし、画面外に出た領域の情報は捨てられてしまいます。スクロールを戻しても画像は再表示されません。


Vimでのスクロール

Neovimでは前方へのスクロール(画像が上へずれる)では追従するものの、後方へのスクロール(画像が下へずれる)では1行でもスクロールすると画像がまるごと消えます。なんで???


Neovimでのスクロール

追従してくれるのは便利ですが、任意の位置に画像を固定したいこともあるでしょう。その場合、画像に合わせて popup window (in Vim) / floating window (in Neovim) を表示しておけばスクロールを止められます。ただし、「サイズ調整が難しい」の部分で書いた通り、フォントサイズの取得が困難であるため、何行何列のウィンドウを表示すればよいのかをVimから調べる事はできません。ユーザーにサイズを設定してもらうしかないと思います。

ステータスラインの表示内容を取得する方法

これは画像表示とは別件ですが、開発中に得たtipsです。

画像がステータスラインの項目に重なってしまうのを回避するため、ステータスラインの現在の表示内容を取得することを試みました。
表示項目は'statusline'オプションに設定されていますが、これはあくまで「どのようにステータスラインを表示するか」のフォーマット文字列です。ここから現在の表示内容(ファイル名など)を取得することはできません。
「ルールに従って'%f'をファイル名に、'%r'を'[RO]'に置換する」のようにできるかもしれませんが、煩雑になりすぎます。言語設定によって表示が変わる項目も存在するので、現実的ではありません。

実のところ、Vim自身にはこれを取得するAPIは用意されていないので、より一般的に「ターミナルの表示を取得する」という方法で対処します。
やりかたはVim本体のテストにありました。

https://github.com/vim/vim/blob/71d0ba07a33a750e9834cd42b7acc619043dedb1/src/testdir/test_statusline.vim#L18-L20

https://github.com/vim/vim/blob/71d0ba07a33a750e9834cd42b7acc619043dedb1/src/testdir/view_util.vim#L19-L36

ScreenLines()はターミナルの任意の行を取得できるように一般化されているので、ステータスライン専用に最適化すると以下のようになります。

function s:get_statusline() abort
  if &laststatus == 0 || (&laststatus == 1 && winnr('$') == 1)
    " statusline is not displayed
    return ''
  endif
  let lnum = &lines-&cmdheight
  return range(1, &columns)->map($'screenstring({lnum}, v:val)')->join('')
endfunction

kawarimilineでは、これをkawarimiline#get_statusline()という関数で使用できます。

おわりに

制限は多いものの、画像表示にはまだ見ぬVimの可能性が隠れていると思います。夢が広がりますね。
利用者が増えて画像表示のユースケースが認知されれば、APIの整備などにもつながると思うので、みなさんもぜひ遊んでみてください。

Discussion