🦇

Neovimから離れずにPDFをプレビューする

に公開

はじめに

最近、Neovimから離れずにPDFを確認したい場面が出てきました。

なるべくTerminal上で完結させたかったため、それを実現できるNeovimプラグインについて調べていました。その中で、プラグインではありませんが、上記の記事を見つけました。

この記事を参考にして、Neovim(Vim)から離れずにPDFを確認する方法を考えてみました。

PDFをTerminal上に表示する

表示させる方法としては、上記の記事のようにoil.nvimとWeztermを使うことに加えて、PDFをTerminal上で表示できるtdf[1]を組み合わせて表示させます。

基本的には上記の記事の内容を使ってPDFを表示させるだけなので、何か特別なことをしているわけではありません。

tdfとは

tdfは、Rust製のターミナルベースPDFビューアです。ratatuiをベースに開発されており、パフォーマンスと応答性に優れ、サイズの大きなPDFでもスムーズに動作するよう設計されています。
PDFだけでなく、PNGやJPEG、BMPなどの画像ファイルも表示できるため、ターミナルベースの環境でファイルをプレビューする際に非常に便利です。

プレビュー表示設定

プレビューを表示させるPaneに送るコマンドは、ファイルの種類に応じて以下のように分岐させます。tdfではPDF以外にもPNGやJPEG, BMPなどの画像ファイルも表示できますが、ここではPDFのみをtdfでの表示対象としています。

PreviewManager.getPreviewCommand = function(abspath, cursor_entry)
    local cmd
    if cursor_entry.type == "file" then
        if FileUtils.isImage(abspath) then
            cmd = "wezterm imgcat"
        elseif FileUtils.isViewableInTdf(abspath) then
            cmd = "tdf"
        else
            cmd = "bat"
        end
    else
        cmd = "ls -l"
    end

    local command = ("%s %s"):format(cmd, abspath)
    return command
end

カーソルのファイルやディレクトリに合わせて適切なコマンドを決定し、そのコマンドをプレビュー用のPaneに送信します。

PDFのページ遷移を操作する

tdfで表示しているPDFのページを次または前のページに遷移させるためには、tdfが実行されているPaneに対してlhといったキーを送信する必要があります。

まず、プレビューを表示させるPaneが現在tdfで開かれているかどうかを確認するために、プレビュー用のPaneの名前を取得し、チェックを行います。

local preview_pane_name = WeztermUtils.getPreviewPaneName()
if preview_pane_name ~= "tdf" then
    vim.notify("TDF is not open in wezterm preview", vim.log.levels.ERROR)
    return
end
WeztermUtils.listPanes = function()
    local cli_result = vim.system({ "wezterm", "cli", "list", ("--format=%s"):format("json") }, { text = true })
        :wait()
    local json = vim.json.decode(cli_result.stdout)
    local panes_list = vim.iter(json):map(lambda(
        "obj: {pane_id = obj.pane_id, tab_id = obj.tab_id, title = obj.title }"))
    return panes_list
end

WeztermUtils.getPreviewPane = function()
    local panes_list = WeztermUtils.listPanes()
    local neovim_wezterm_pane_id = WeztermUtils.getNeovimPaneId()
    local current_tab_id = assert(panes_list:find(function(obj)
        return obj.pane_id == neovim_wezterm_pane_id
    end)).tab_id
    local preview_pane = panes_list:find(function(obj)
        return
            obj.tab_id == current_tab_id
            and tonumber(obj.pane_id) >
            tonumber(neovim_wezterm_pane_id)
    end)
    return preview_pane
end

WeztermUtils.getPreviewPaneName = function()
    local preview_pane = WeztermUtils.getPreviewPane()
    return preview_pane ~= nil and preview_pane.title or nil
end

次に、プレビューのPaneに対してlhといったキーを送信します。

WeztermUtils.sendKeyToPreviewPane = function(wezterm_pane_id, key)
    local cmd = {
        "wezterm",
        "cli",
        "send-text",
        ("'%s'"):format(key),
        "--no-paste",
        ("--pane-id=%s"):format(wezterm_pane_id)
    }
    vim.fn.system(table.concat(cmd, " "))
end
以下のコマンドでも同様の操作が実現できます
    local cmd = {
        "wezterm",
        "cli",
        "send-text",
        ("'%s'"):format(key),
        ("--pane-id=%s"):format(wezterm_pane_id)
    }

もしくは

    local cmd = {
        "echo",
        "-n",
        ("'%s'"):format(key),
        "|",
        "wezterm",
        "cli",
        "send-text",
        "--no-paste",
        ("--pane-id=%s"):format(wezterm_pane_id)
    }

この仕組みを利用することで、lh以外にもtdfの様々な操作コマンドをNeovimから実行できるようになります。

M.tdfNext = PreviewManager.createTdfNavigationAction("l")
M.tdfPrev = PreviewManager.createTdfNavigationAction("h")
M.tdfFullScreen = PreviewManager.createTdfNavigationAction("f")
M.tdfInvert = PreviewManager.createTdfNavigationAction("i")
    ["gl"] = actions.tdfNext,
    ["gh"] = actions.tdfPrev,
    ["gs"] = actions.tdfFullScreen,
    ["gv"] = actions.tdfInvert,

結果

動作は以下のようになります。

コード全体
local M = {}

local debounce = require("core.utils").debounce

local function getEntryAbsPath()
    local oil = require("oil")
    local cursor_entry = oil.get_cursor_entry()
    local current_dir = oil.get_current_dir()
    if not cursor_entry or not current_dir then
        return
    end
    return current_dir .. cursor_entry.name, cursor_entry, current_dir
end

-- Wezterm utilities
local WeztermUtils = {}

WeztermUtils.listPanes = function()
    local cli_result = vim.system({ "wezterm", "cli", "list", ("--format=%s"):format("json") }, { text = true })
        :wait()
    local json = vim.json.decode(cli_result.stdout)
    local panes_list = vim.iter(json):map(lambda(
        "obj: {pane_id = obj.pane_id, tab_id = obj.tab_id, title = obj.title }"))
    return panes_list
end

WeztermUtils.getNeovimPaneId = function()
    local wezterm_pane_id = vim.env.WEZTERM_PANE
    if not wezterm_pane_id then
        vim.notify("Wezterm pane not found", vim.log.levels.ERROR)
        return
    end
    return tonumber(wezterm_pane_id)
end

WeztermUtils.getPreviewPane = function()
    local panes_list = WeztermUtils.listPanes()
    local neovim_wezterm_pane_id = WeztermUtils.getNeovimPaneId()
    local current_tab_id = assert(panes_list:find(function(obj)
        return obj.pane_id == neovim_wezterm_pane_id
    end)).tab_id
    local preview_pane = panes_list:find(function(obj)
        return
            obj.tab_id == current_tab_id
            and tonumber(obj.pane_id) >
            tonumber(neovim_wezterm_pane_id) -- new pane id should be greater than the current one
    end)
    return preview_pane
end

WeztermUtils.getPreviewPaneId = function()
    local preview_pane = WeztermUtils.getPreviewPane()
    --  preview_pane ~= nil ? preview_pane.pane_id : nil
    return preview_pane ~= nil and preview_pane.pane_id or nil
end

WeztermUtils.getPreviewPaneName = function()
    local preview_pane = WeztermUtils.getPreviewPane()
    return preview_pane ~= nil and preview_pane.title or nil
end

WeztermUtils.activatePane = function(wezterm_pane_id)
    local cmd = { "wezterm", "cli", "activate-pane", ("--pane-id=%s"):format(wezterm_pane_id) }
    vim.system(cmd)
end

WeztermUtils.openNewPane = function(opt)
    local _opt = opt or {}
    local percent = _opt.percent or 30
    local direction = _opt.direction or "right"

    local cmd = { "wezterm", "cli", "split-pane", ("--percent=%d"):format(percent), ("--%s"):format(direction), "--",
        "bash" }
    local obj = vim.system(cmd, { text = true }):wait()
    local wezterm_pane_id = assert(tonumber(obj.stdout))

    return wezterm_pane_id
end

WeztermUtils.closePreviewPane = function(wezterm_pane_id)
    vim.system({ "wezterm", "cli", "kill-pane", ("--pane-id=%s"):format(wezterm_pane_id) })
end

WeztermUtils.sendCommandToPreviewPane = function(wezterm_pane_id, command)
    local cmd = {
        "echo",
        ("'%s'"):format(command),
        "|",
        "wezterm",
        "cli",
        "send-text",
        "--no-paste",
        ("--pane-id=%s"):format(wezterm_pane_id)
    }
    vim.fn.system(table.concat(cmd, " "))
end

WeztermUtils.sendKeyToPreviewPane = function(wezterm_pane_id, key)
    local cmd = {
        "wezterm",
        "cli",
        "send-text",
        ("'%s'"):format(key),
        "--no-paste",
        ("--pane-id=%s"):format(wezterm_pane_id)
    }
    vim.fn.system(table.concat(cmd, " "))
end

WeztermUtils.ensurePreviewPane = function(opt)
    local preview_pane_id = WeztermUtils.getPreviewPaneId()
    if preview_pane_id == nil then
        preview_pane_id = WeztermUtils.openNewPane(opt)
    end
    return preview_pane_id
end

-- File type utilities
local FileUtils = {}

FileUtils.isImage = function(url)
    local extension = url:match("^.+(%..+)$")
    local imageExt = { ".bmp", ".jpg", ".jpeg", ".png", ".gif", ".tif", ".tiff" }
    return vim.iter(imageExt):any(function(ext)
        return extension == ext
    end)
end

FileUtils.isViewableInTdf = function(url)
    local extension = url:match("^.+(%..*)$")
    local tdfExt = { ".pdf", ".bmp", ".jpg", ".jpeg", ".png" }
    return vim.iter(tdfExt):any(function(ext)
        return extension == ext
    end)
end

-- Preview manager
local PreviewManager = {}

PreviewManager.getPreviewCommand = function(abspath, cursor_entry)
    local cmd
    if cursor_entry.type == "file" then
        if FileUtils.isImage(abspath) then
            cmd = "wezterm imgcat"
        elseif FileUtils.isViewableInTdf(abspath) then
            cmd = "tdf"
        else
            cmd = "bat"
        end
    else -- directory, link, etc
        cmd = "ls -l"
    end

    local command = ("%s %s"):format(cmd, abspath)
    return command
end

PreviewManager.createPreviewAction = function(config)
    local paneOpts = { percent = config.percent or 30, direction = config.direction or "right" }

    return {
        callback = function()
            local open_preview_pane_id = WeztermUtils.getPreviewPaneId()
            if open_preview_pane_id ~= nil then
                WeztermUtils.closePreviewPane(open_preview_pane_id)
            end

            local oil = require("oil")
            local oil_util = require("oil.util")
            local preview_entry_id = nil

            local neovim_wezterm_pane_id = WeztermUtils.getNeovimPaneId()
            local bufnr = vim.api.nvim_get_current_buf()

            local updateWeztermPreview = debounce(
                vim.schedule_wrap(function()
                    if vim.api.nvim_get_current_buf() ~= bufnr then
                        return
                    end
                    local cursor_entry = oil.get_cursor_entry()
                    if cursor_entry ~= nil and not oil_util.is_visual_mode() then
                        local preview_pane_id = WeztermUtils.ensurePreviewPane(paneOpts)
                        WeztermUtils.activatePane(neovim_wezterm_pane_id)

                        if preview_entry_id == cursor_entry.id then
                            return
                        end

                        local preview_pane_name = WeztermUtils.getPreviewPaneName()
                        if vim.fn.index({ "bat", "tdf" }, preview_pane_name) ~= -1 then
                            WeztermUtils.sendTextToPreviewPane(preview_pane_id, "q")
                        end

                        local abspath = assert(getEntryAbsPath())
                        local command = PreviewManager.getPreviewCommand(abspath, cursor_entry)

                        WeztermUtils.sendCommandToPreviewPane(preview_pane_id, command)
                    end
                end),
                50
            )

            updateWeztermPreview()

            local config_oil = require("oil.config")
            if config_oil.preview_win.update_on_cursor_moved then
                vim.api.nvim_create_autocmd("CursorMoved", {
                    desc = "Update oil wezterm preview",
                    group = "Oil",
                    buffer = bufnr,
                    callback = function()
                        updateWeztermPreview()
                    end,
                })
            end

            vim.api.nvim_create_autocmd({ "BufLeave", "BufDelete", "VimLeave" }, {
                desc = "Close oil wezterm preview",
                group = "Oil",
                buffer = bufnr,
                callback = function()
                    WeztermUtils.closePreviewPane(WeztermUtils.getPreviewPaneId())
                end,
            })
        end,
        desc = "Open Preview with Wezterm"
    }
end

PreviewManager.createTdfNavigationAction = function(key)
    return {
        callback = function()
            local oil = require("oil")
            local oil_util = require("oil.util")
            local preview_entry_id = nil

            local neovim_wezterm_pane_id = WeztermUtils.getNeovimPaneId()
            local bufnr = vim.api.nvim_get_current_buf()

            local updateTdfNavigation = debounce(
                vim.schedule_wrap(function()
                    if vim.api.nvim_get_current_buf() ~= bufnr then
                        return
                    end
                    local cursor_entry = oil.get_cursor_entry()
                    if cursor_entry ~= nil and not oil_util.is_visual_mode() then
                        local preview_pane_name = WeztermUtils.getPreviewPaneName()
                        if preview_pane_name ~= "tdf" then
                            vim.notify("TDF is not open in wezterm preview", vim.log.levels.ERROR)
                            return
                        end
                        local preview_pane_id = WeztermUtils.ensurePreviewPane()
                        WeztermUtils.activatePane(neovim_wezterm_pane_id)

                        if preview_entry_id == cursor_entry.id then
                            return
                        end
                        WeztermUtils.sendKeyToPreviewPane(preview_pane_id, key)
                    end
                end),
                50
            )
            updateTdfNavigation()
        end
    }
end

M.openWithQuickLook = {
    callback = function()
        local abspath = assert(getEntryAbsPath())
        require("core.utils").open_file_with_quicklook(abspath)
    end,
    desc = "Open Preview with QuickLook"
}

M.openWithWeztermPreview = PreviewManager.createPreviewAction({
    percent = 50,
    direction = "right"
})

M.closeWeztermPreview = {
    callback = function()
        local preview_pane_id = WeztermUtils.getPreviewPaneId()
        if preview_pane_id then
            WeztermUtils.closePreviewPane(preview_pane_id)
        end
    end,
    desc = "Close Preview"
}

M.tdfNext = PreviewManager.createTdfNavigationAction("l")
M.tdfPrev = PreviewManager.createTdfNavigationAction("h")
M.tdfFullScreen = PreviewManager.createTdfNavigationAction("f")
M.tdfInvert = PreviewManager.createTdfNavigationAction("i")

return M

まとめ

本記事では、Neovimから離れることなくPDFを確認する方法として、Weztermとtdfを組み合わせてPDFをプレビューする方法を紹介しました。
ぜひ、NeovimでのQOLを向上させるためにご活用ください。

参考

脚注
  1. tdf, A tui-based PDF viewer ↩︎

Discussion