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に対してlやhといったキーを送信する必要があります。
まず、プレビューを表示させる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に対してlやhといったキーを送信します。
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)
}
この仕組みを利用することで、lやh以外にも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を向上させるためにご活用ください。
Discussion