あめだまふぁくとりー

Boost.Graphとかできますん

Neovim 上で Sixel 画像を表示する

Neovim 上で Sixel 画像を表示する(といっても,実際は Noevim をバイパスして,ターミナルエミュレータが表示するわけだが).

環境は macOS,iTerm2 を使用した. Sixel 画像の生成には timg を使用したが,いろいろ面倒なので img2sixel を使った方が楽にできる(詳細は後述).

chansend だとうまくいかない

Neovim から Sixel 画像を表示するには,こちら にも記載のある通り,tty に対して直接 Sixel データを書き込むとよい.

上記の参照先では tty にデータを書き込むのに chansend を使用すれば良いとあるのだが,実際にやってみると画像が全く表示されなかったり,一部だけ表示されたりでうまくいかない(Vimechoraw を使った場合はうまくいく). 特に画像が大きくなればなるほど失敗する傾向にある.

調べた限りだと,chansend による tty への書き込みは失敗する場合や部分的にしか書き込めない場合が多分あるようである. なので,以下のようにして chansend を複数回呼ぶようにして見た.

  • chansend の戻り値(書き込めたデータの長さ)を見て,書き込めなかった分はリトライする
  • 一度に書き込むデータを最大 32 Byte になるよう,書き込むデータを分割する

これをすることでだいぶ安定して画像を表示できるようになったが,それでも画像が乱れる場合があったので chansend を使う方法を諦めることとした(ちなみにリトライする際に sleep を入れたら乱れが余計にひどくなった).

非同期 API を使用する

Neovim には libUV を使用した非同期 API が用意されており,こちらを使用すると綺麗に画像を表示することができた. 既存の画像表示プラグインもこちらの方法を使用している.

local stderr = vim.loop.new_tty(2, false)
stderr:write(sixel_data)

ただし,stderr:write は書き込みが完了していないデータが存在する場合は失敗するので連続して画像を書き込む場合は,先行する書き込みが完了してから書き込みを行う必要がある.

-- 書き込み待ちキュー
local write_queue = {}
local stderr = vim.loop.new_tty(2, false)
local write_callback
write_callback = function(err)
    if err then
        return
    end
    table.remove(write_queue, 1)
    if #write_queue > 0 then
        -- 書き込み待ちデータが存在する場合は,待ちデータの書き込みを実行
        stderr:write(write_queue[1], write_callback)
    end
end

-- 書き込み処理
table.insert(write_queue, sixel_data)
if #write_queue == 1 then
    -- 書き込み待ちデータが存在しない場合のみ書き込みを実行
    stderr:write(write_queue[1], write_callback)
end

timg を使用した Sixel 画像の表示

ここまでの内容で Neovim 上で画像を表示することはできるようになったのが,Neovim から timg を起動して Sixel 画像を生成する場合にも注意点がある.

Sixel の DCS が二重になっている

これは Neovim は関係ないのだが,timg が生成する Sixel データはなぜか DCS が二重になっており,そのままでは iTerm2 では画像を表示できなかった(WezTerm の場合は表示できた).

通常の Sixel の DCS は DCS q ST なのだが timg が出力するものは DSC q DCS q ST ST となっていた.

invisible-island.net

しょうがないので二回目の DCS q から一回目の ST までを抜き取るようにして,抜き取った部分だけを tty に書き込むようにした.

GIF アニメーションがされない

ターミナルエミュレータから直接 timg を実行した場合は GIF アニメーションが再生されるのに,jobstart で timg に Sixel 形式で画像を出力させてみると再生されないことがわかった.

Neovim の terminal から timg を実行すると以下のメッセージが表示された.

Terminal does not support pixel size query, but with sixel graphics this is needed to show animations or columns.
File an issue with your terminal implementation to implement ws_xpixel, ws_ypixel on TIOCGWINSZ or "\033[16t" query.
Can't show animations or have columns in grid.
(Suggestion: switch back to --pixelation=quarter for now)

timg が DCS 16 t でセルあたりのピクセルサイズを取得しにきているがそれに対して応答していないため,GIF アニメーションができていないことがわかった(どうやら,カーソル位置を画像の先頭に持ってくるのに必要なようだ).

他にも情報を取得しにきていたので,以下のように諸々返すようにしたら GIF アニメーションも再生できるようになった.

local buffer = ""
local jobid = vim.fn.jobstart({ "timg", "-p", "s", "-U", path }, {
    clear_env = true,
    pty = true,
    width = 200,
    height = 80,
    on_stdout = function(jobid, data, _)
        for _, line in pairs(data) do
            if line == "" then
                return
            elseif line == "\x1b[16t" then
                -- Pixel サイズ 28x14
                vim.fn.chansend(jobid, "\x1b[6;28;14")
                return
            elseif line == "\x1b[>q\x1b[5n" then
                -- 端末情報
                vim.fn.chansend(jobid, "\x1bP>|libvterm(0.3)\x1b\\\x1b[0n")
                return
            elseif line == "\x1b]11;?\x1b\\" then
                -- 背景色
                vim.fn.chansend(jobid, "\x1b]11;rgb:3333/3333/3333\x1b\\")
                return
            end

            local pos1, pos2 = line:find("\x1b\\")
            if pos1 == nil then
                buffer = buffer .. line
                return
            end

            buffer = buffer .. line:sub(1, pos2)
            local sixel_pos = buffer:find("\x1bPq\x1bPq")
            if sixel_pos ~= nil then
                buffer = buffer:sub(sixel_pos + 3)
                -- 25: hide cursor
                -- 80: show sixel in other window
                -- 7730: sixel scroll left mode
                -- 8452: sixel scroll right mode
                buffer = "\x1b[?25l\x1b[80h\x1b[?7730h\x1b[?8452l" .. buffer
                -- 表示位置指定
                buffer = "\x1b[%d;%dH":format(0, 0)
                table.insert(write_queue, buffer)
                if #write_queue == 1 then
                    stderr:write(write_queue[1], write_callback)
                end
            end
            buffer = line:sub(pos2 + 1)
        end
    end,
    stdout_buffered = false,
})

後片付け

これで表示は完全にできるようになったのだが,これを使って画像のプレビューを何度もしていると iTerm2 の仮想メモリが上昇し続けていることに気がついた. 表示した画像は normal! <C-L> で消しているだが,どうやら iTerm2 は内部で表示した Sixel 画像の情報をずっと保持しているままのようだった.

⌘+K で iTerm2 のバッファを消してあげると使用メモリは減ったのだが,これを毎回手動でやる気にはなれない.

iTerm2 のドキュメントを見てみると,特定の OSC を送信するとバッファを削除できることがわかった. これを画像削除時に送信してやることで仮想メモリの上昇を抑えることができた.

if vim.env.TERM_PROGRAM == "iTerm.app" then
    vim.fn.chansend(vim.v.stderr, "\x1b\\\x1b]1337;ClearScrollback\x1b\\")
end

余談

画像表示には iTerm2 プロトコルを使用することもできたのだが,アニメーション再生が iTerm2 プロトコルだともっさりしていたので今回は Sixel を使用するようにした. ただ,Sixel だと背景透過ができず,iTerm2 プロトコルだとできるのでパフォーマンスを改善できるなら iTerm2 プロトコルを使用したい.