はじめに
この記事は親記事から派生したもので、nvim-tree.luaのプラグイン魔改造についてを取り扱っています。
nvimに慣れてくると「この機能をこうしたい」や「あんな機能が欲しいな」と言ったようにプラグイン本体を改造したくなってくると思います。本記事では、初心者がプラグイン(nvim-tree/nvim-tree.lua)をフォークして、ソースコードを手探っていく過程をできるだけ詳細に記述しました。みなさんのプラグイン改造に役立てると幸いです。
それでは、
レッツフォーク!
序章
前準備
まずはプラグインのソースコードを編集できる段階までの準備についてです。プラグインをインストールするところまでは、こちらの記事を参考にしてください。
私はプラグイン管理はlazy.nvimで行っています。lazy.nvimを使っている場合、プラグインのソースコードは ~/.local/share/nvim/lazy/ディレクトリにgit cloneされます。nvim-tree.luaの場合は~/.local/share/nvim/lazy/nvim-tree.luaといった感じです。ディレクトリの場所が確認できたら準備完了です!
余談ですが、プラグインの編集を行う際にはvscodeを使いました。一番最初にテキストエディタを作った人は、便利な開発ツールがない中でコードをたくさん書いてたと思うとすごいですね…
概観の把握
何も知らないところから始めたので、まずはターミナルでtreeコマンドを実行してnvim-tree.lua全体の構成を把握しようとしました。treeコマンドが使えない場合は以下のコマンドでインストールしてください。
sudo apt update
sudo apt install nvim
treeコマンドのオプションには-d(ディレクトリのみ表示)や-L(数字を指定して何階層分表示するかを指定)があります。tree単体で実行すると、23ディレクトリ、99ファイルと表示されました。多いですね。各ファイルは500行程で、想像してたよりもコードの量が多くて驚きました。
図1 treeコマンドによりフォルダのみを表示した
nvim-tree.lua/luaにnvim-trree.luaという全体のコードがあり、さまざまな機能が実装されているコードはnvim-tree.lua/lua/nvim-treeの下にありました。
改造①:ツリーにフォーカスしている間はカーソルを非表示にする
テーマ設定の理由
nvim-treeはデフォルトでは、以下の画像のようにツリーでファイルを選ぶときにカーソルの色が目立ってしまっています。選択している行は大事ですが、カーソル自体がどこにあるのかは見えないようになっていた方がおしゃれだと思い、「nvim-treeのウィンドウにフォーカスされている間のみカーソルを非表示にする」という改造を施すことにしました。
実装
オートコマンドの設定
最初に概観を確認した際に、nvim-tree.lua/lua/nvim-tree/view.luaという見た目を司っているコードを発見したのでこのコードの中をまず調べました。初め、set_window_options_and_buffer()というwindowのオプションを設定する関数を見つけて、カーソルの色を変える設定を追加してみました。しかし、このコードは初期化のタイミングでのみ呼び出されているらしく、はじめにnvim-treeを開いた瞬間からnvimを終了するまでずっと設定が適用されてしまったため、失敗でした。
ここで、バッファが切り替わったことを検知して自動で実行されるコードがあるはずだと思い、ネットでオートコマンドについて調べました。そうすると、nvimではvim.api.nvim_create_autocmdというAPIが用意されていることが判明しました。これを使うことでさまざまなイベントによってトリガされる関数を作れるようです。このキーワードで文字列検索したところnvim-tree.luaで1箇所使われていました。それが以下の関数です。nvim-tree.luaの前半部分(145行目のあたり)で定義されています。
local augroup_id = vim.api.nvim_create_augroup("NvimTree", { clear = true })
local function create_nvim_tree_autocmd(name, custom_opts)
local default_opts = { group = augroup_id }
vim.api.nvim_create_autocmd(name, vim.tbl_extend("force", default_opts, custom_opts))
end
このコードはnvim-tree内で使うオートコマンドを定義するための関数です。この部分の下でオートコマンドが複数定義されていました。nvimで使えるイベントはこのドキュメントにまとまっていて、今回使うのは以下の二つです。
-
BufEnter: After entering a buffer. Useful for setting options for a file type. Also executed when starting to edit a buffer. -
BufLeave: Before leaving to another buffer. Also when leaving or closing the current window and the new current window is not for the same buffer.
名前の通りですね。他のオートコマンドの定義を参考にしながら、以下のようにview.hide_cursor()とview.show_cursor()関数が呼び出されるように設定しました。
create_nvim_tree_autocmd("BufEnter", {
pattern = "NvimTree_*",
callback = function()
view.hide_cursor()
end,
})
create_nvim_tree_autocmd("BufLeave", {
pattern = "NvimTree_*",
callback = function()
view.show_cursor()
end,
})
nvim-tree.luaのはじめの部分でlocal view = require("nvim-tree.view")(nvim-treeディレクトリの下にあるview.luaをviewという変数に読み込んでいる)と定義されているので、view.hide_cursor()と記述するだけでview.luaで定義されているhide_cursor()が呼び出せるようになっています。
また、pattern = "NvimTree_*"という設定によってnvim-treeに関連したバッファのみを対象にイベントが発火するようにしました。
hide_cursor() の実装
ここまでも手探りながら、逐次nvimに反映させて確認しながらやっていたので大変だったのですが、ここからカーソルの色を変える部分が非常に厄介でした。
図 ハイライトグループの一部
まずはじめに、nvimにはハイライトグループという概念があり、グループごとに背景色や前景色の色の設定を持っています。例えば、cursorlineがtrueになっている場合、つまり、カーソルのある行を強調表示する設定になっている場合は、その行の背景色と文字の色はCursorLineというハイライトグループによって設定されることになっています。カーソルの色の場合はguicursorにリンクされているハイライトグループの設定が適用されます。デフォルトでは以下のように設定されていました。
guicursor=n-v-c-sm:block,i-ci-ve:ver25,r-cr-o:hor20,t:block-blinkon500-blinkoff500-TermCursor
これは、n(Normalモード), v(Visualモード), c(Command-lineモード), sm(Showモード)の時に形状をブロックにして、i(Insertモード), ci(Command-line-Insertモード), ve(Visual Exclusiveモード)の時に形状を文字幅の25%の太さの縦線カーソルにするといったことが設定されています。block-blinkon500-blinkoff500-TermCursorというようにハイフンで繋ぐことで、形状:ブロック、点滅:500ms間隔、ハイライトグループ:TermCursorと言うように複数の要素を一度に決めることもできます。
今回実装方法を考える中で、初めはカーソルの有無を選べるだろうと思い、カーソルを完全消去する方針で調べていました。ですが、カーソルをなくすことはできないようだったので、カーソルの色を行の背景色と前景色に一致させることで見えない(見分けがつかない)ようにすることにしました。結論を言うと、iterm2など、ターミナルアプリによっては前景色がターミナルアプリによって決定されてしまうため、この実装方法も事実上不可能であることが判明しました。カーソルの背景色を行の背景色に一致させた上で、カーソルを細い棒にするのが最善だと考え、最終的にはこの方針で実装しました。以下がそのコードです。
このコードは、以下の操作をします。
- ハイライトグループの追加:背景色をCursorLineの背景色、前景色をNormalの前景色と同じにしたハイライトグループ
NvimTreeCursorInvisibleを追加します - guicursorの要素分解:カンマ区切りにして分析しやすくします
- 置き換え部分の検索:ノーマルモードの設定を行なっているブロックを探します
- 設定の置き換え:該当部分を
ver01-NvimTreeCursorInvisibleに置き換えます - 連結・反映:要素を連結し、guicursorに反映させます
これによってカーソルの形状が細い棒状になり、色も行の背景色と同じになりました。
図 hide_cursor()実行後のnvim_tree
function M.hide_cursor()
local hl_options = {}
local cursorline_hl_data = vim.api.nvim_get_hl_by_name("CursorLine", true)
local cursornormal_hl_data = vim.api.nvim_get_hl_by_name("Normal", true)
local bg_color = cursorline_hl_data.background
local fg_color = cursornormal_hl_data.foreground
if bg_color then hl_options.bg = bg_color end
if fg_color then hl_options.fg = fg_color end
local target_group = "NvimTreeCursorInvisible"
if next(hl_options) ~= nil then
vim.api.nvim_set_hl(0, target_group, hl_options)
local current_guicursor = vim.api.nvim_get_option_value("guicursor", {})
local new_guicursor_parts = {}
for setting in string.gmatch(current_guicursor, "[^,]+") do
local mode_prefix, rest = setting:match("^([%w%-]+):(.+)$")
if mode_prefix and rest then
if mode_prefix:find("n") then
table.insert(new_guicursor_parts, mode_prefix .. ":" .. "ver01-" .. target_group)
else
table.insert(new_guicursor_parts, setting)
end
else
table.insert(new_guicursor_parts, setting)
end
end
local new_guicursor = table.concat(new_guicursor_parts, ",")
vim.api.nvim_set_option_value("guicursor", new_guicursor, { scope = "local" })
else
print("Failed to get valid color data for highlight.")
end
end
show_cursor() の実装
次にnvim-treeから他のバッファに移るときに、カーソルの色の設定を元に戻す関数 show_cursor() の実装方法についてです。こちらの関数は先ほどよりもシンプルで、先ほど同様ノーマルモードの設定が含まれるまとまりの設定をblockに戻すという操作をするだけです。大まかな流れは先ほどと同じです。
function M.show_cursor()
local current_guicursor = vim.api.nvim_get_option_value("guicursor", {})
local new_guicursor_parts = {}
for setting in string.gmatch(current_guicursor, "[^,]+") do
local mode_prefix, rest = setting:match("^([%w%-]+):(.+)$")
if mode_prefix and rest then
if mode_prefix:find("n") then
table.insert(new_guicursor_parts, mode_prefix .. ":" .. "block")
else
table.insert(new_guicursor_parts, setting)
end
else
table.insert(new_guicursor_parts, setting)
end
end
local new_guicursor = table.concat(new_guicursor_parts, ",")
vim.api.nvim_set_option_value("guicursor", new_guicursor, { scope = "local" })
end
動いている様子
nvim-treeのウィンドウの下にカーソルの座標が表示されています。座標からカーソルが上下左右に動いていることが分かりますが、目で見てもどこにあるのかわからないようになりました!
改造② 矢印キーでフォルダの展開・縮小をできるようにする
テーマ設定の理由
ツリー内の移動は矢印で、フォルダの展開や折りたたみはenterで行いますが、VS Codeの名残で左右の矢印でフォルダの展開・折りたたみをしたくなるのでこのような改造をすることにしました。
実装
今度はキーボード入力をキャッチして関数を呼び出します。元々実装されているenterを押すとフォルダが展開されたり、ファイルが開かれたりする機能を参考にして実装していこうと思います。
まず、enterの設定をしているファイルを探します。ちなみに、nvimで使われている特殊キーの表記は僕が今回知っている範囲では以下のものがあります。
| キー | 特殊キー表記 |
|---|---|
| Enter | <CR> |
| Tab | <Tab> |
| Delete | <Del> |
| Backspace | <BS> |
| Shift + | <S- > |
| Control | <C- > |
| Alt | <A- > |
| Escape | <Esc> |
| スペース | <Space> |
| 上矢印 | <Up> |
| 下矢印 | <Down> |
| 左矢印 | <Left> |
| 右矢印 | <Right> |
view.luaと同じ階層(nvim-tree.lua/lua/nvim-tree/)にkeymap.luaというファイルがありました。この中で<CR>を探すと、以下の1行が見つかりました。
vim.keymap.set("n", "<CR>", api.node.open.edit, opts("Open"))
opts()はこの前の部分で以下のように適宜されていました。キーマップの説明にデフォルトでnvim-tree: という言葉がつくようになっています。また、api.node.open.editはnvim-tree/api.luaというファイルで定義されているnode.open.editという関数のようです。
---@param bufnr integer
function M.default_on_attach(bufnr)
local api = require("nvim-tree.api")
local function opts(desc)
return {
desc = "nvim-tree: " .. desc,
buffer = bufnr,
noremap = true,
silent = true,
nowait = true,
}
end
そこで、次はnvim-tree/api.luaの中を見ていこうと思います。このファイルの中には以下のように設定されていました。
Api.node.open.edit = wrap_node(open_or_expand_or_dir_up("edit"))
この関数の中で呼ばれているexpand_or_collapse()関数は定義を遡っていくとnvim-tree/node/directory.luaにありました。同じファイルの中で、折りたたみだけするcollapse()関数と、展開だけするexpand()関数を実装していきます。
元にしたexpand_or_collapse()関数の定義は以下のとおりです。
---@param toggle_group boolean?
function DirectoryNode:expand_or_collapse(toggle_group)
toggle_group = toggle_group or false
if self.has_children then
self.has_children = false
end
if #self.nodes == 0 then
self.explorer:expand(self)
end
local head_node = self:get_parent_of_group() or self
if toggle_group then
head_node:toggle_group_folders()
end
local open = self:last_group_node().open
local next_open
if toggle_group then
next_open = open
else
next_open = not open
end
local node = head_node
while node do
node.open = next_open
node = node.group_next
end
self.explorer.renderer:draw()
end
この定義の中から展開する機能を無くして、collapse()関数は以下のようになりました。#self.nodes == 0の時にexpand(self)が呼び出される部分を無くし、開閉状態を決めているopen, next_openが必ずfalseになるようにしています。
function DirectoryNode:collapse()
if self.has_children then
self.has_children = false
end
local head_node = self:get_parent_of_group() or self
local open = self:last_group_node().open
local next_open
next_open = false
local node = head_node
while node do
node.open = next_open
node = node.group_next
end
self.explorer.renderer:draw()
end
expand()の方はnext_openをtrueにすることで必ず開かれるようになっています。
function DirectoryNode:expand()
if self.has_children then
self.has_children = false
end
if #self.nodes == 0 then
self.explorer:expand(self)
end
local head_node = self:get_parent_of_group() or self
local open = self:last_group_node().open
local next_open
next_open = true
local node = head_node
while node do
node.open = next_open
node = node.group_next
end
self.explorer.renderer:draw()
end
次に、キーボードが押された時にこれらの関数が呼び出されるように設定します。まずはapi.luaにapiを呼び出せるよう設定を書きます。
---@return fun(node: Node, edit_opts: NodeEditOpts?)
local function collapse()
---@param node Node
---@param edit_opts NodeEditOpts?
return function(node, edit_opts)
local dir = node:as(DirectoryNode)
if dir then
dir:collapse()
end
end
end
Api.node.open.collapse = wrap_node(collapse())
---@return fun(node: Node, edit_opts: NodeEditOpts?)
local function expand()
---@param node Node
---@param edit_opts NodeEditOpts?
return function(node, edit_opts)
local root = node:as(RootNode)
local dir = node:as(DirectoryNode)
if dir then
dir:expand(toggle_group)
end
end
end
Api.node.open.expand = wrap_node(expand())
この設定したapi.node.open.collapseとapi.node.open.expandを用いてキーマップを設定します。今回は、本家の書き方に近くなるように、directory.luaとapi.lua, keymap.luaに分けて記述しましたが、keymap.luaに直接呼び出す関数ごと定義することもできると思います。
vim.keymap.set("n", "<Left>", api.node.open.collapse, opts("Collapse"))¥
vim.keymap.set("n", "<Right>", api.node.open.expand, opts("Expand"))
動いている様子
矢印キーを使って展開・折りたたみをしている様子残念ながら矢印キーを使っているかがわかりませんが、展開・折りたたみができていることが確認できると思います。ちゃんと、左矢印を押したときは折り畳まれるだけで展開はされず、右矢印を押したときはその逆になっています。
終わりに
ツリーを操作するときだけカーソルが表示されなくなり、矢印でフォルダの展開と折りたたみができるようになりました!これで一歩VS Codeに近づきましたね 🙃
主目的ではありませんでしたが、今回の課題に取り組むにあたり憧れていたNeoVimを導入できて非常に嬉しいです。
今回の実験を通して、大規模なプログラムで自分が知りたい機能を実現している部分を素早く把握する方法を身につけることができたと思います。そして何より、ツールを駆使すれば大規模なプログラムでも何とか太刀打ちすることができるのだという自信を少しはつけられたと感じます。
また、オープンソフトウェアとそのプラグインを調べていく中で、git issueもたくさん読み、どのようにしてたくさんの人が協力してプログラムを作り上げていく様子の解像度が上がったのは予想外の収穫でした。forkの数やstarの数、contributerの人数などを見て、オープンソフトウェア開発の力を感じました。
今回のテーマはissueを元に設定したものなので、contributeするためにコードの一貫性を再度確認してプルリクを出してみようと思います。
最後までお読みいただきありがとうございました。






