☁️

NeoVimのFloating WindowでPopup Menuを作ってみた

に公開

この記事はVim駅伝の2025/04/18分の記事です。

最近VimからNeoVimに移行しまして、Luaに慣れるべくFloating WindowでPopup Menuを作ってみました。コードや使い方はGitHubにもあります。

Floating Window

NeoVimにはFloating Windowと呼ばれる、標準ウィンドウの上に描画可能なウィンドウがあります。これにより、ウィンドウを複数重ねることができるようになりました。

Floating Windowは以下のようにAPIから利用することができます。

local win_id = vim.api.nvim_open_win(buffer, enter, config)

bufferはFloating Windowに表示するバッファ、enterはウィンドウにカーソルを移動するかどうか、configはウィンドウの設定テーブルです。そして返り値としてウィンドウ番号を返します。

NeoVimでFloating Windowが登場するまで、VimではPopup機能を用いてウィンドウを重ねて描画していました(補完の出力やballoonはまた別の仕組み?のようですが)。Popup機能はVim8から搭載されています。

PopupにはPopup Menuというリスト専用機能があります。

popup_menu(list, config)

Popup Menuはlistの内容を表示し、選択したlistの項目をconfig中で指定したcallbackに渡し、処理を実行できる、というものです。

このPopup Menuはかなり便利なのですが、NeoVimのFloating Windowにはこの機能はありません。
そこで、今回はLuaの勉強がてらPopup Menuを作ってみました。

コード

全コードは以下です。

local M = {}
local api = vim.api

vim.cmd("highlight PopupMenuFloatBorder guifg=#006db3")
vim.cmd("highlight PopupMenuFloatTitle guifg=#6ab7ff")
vim.cmd("highlight PopupMenuText guifg=#abb2bf")
vim.cmd("highlight PopupMenuTextSelected guifg=#abb2bf guibg=#383c44")

---@param popup_table table<string>
---@param opt table<T>
---@param callback function()
---@return callback(string)
function M.popup_menu(popup_table, opt, callback)
    local buffer = api.nvim_create_buf(false, true)

	if opt == nil then
        opt = {
		    start_index = 1,
		    relative = 'cursor',
		    row = 0,
		    col = 0,
		    width = 40,
		    height = 5,
		    border = 'rounded',
			title = 'popup_menu',
		    zindex = 1,
		}
    end

    opt.title = { { opt.title, 'PopupMenuFloatTitle' } }

    local start_index = opt.start_index
    local cursor_pos = opt.start_index
    local height = math.min(opt.height, #popup_table)

    -- popup_windowを作成
    local window = api.nvim_open_win(buffer, true, {
		relative = opt.relative,
		row = opt.row,
		col = opt.col,
		width = opt.width,
		height = height,
		focusable = true,
		border = opt.border,
		title = opt.title,
		title_pos = 'left',
		noautocmd = true,
		zindex = opt.zindex,
    })
    
    api.nvim_win_set_option(window, 'number',  false)
    api.nvim_win_set_option(window, 'relativenumber', false)
    api.nvim_win_set_option(window, 'wrap',  false)
    api.nvim_win_set_option(window, 'cursorline',  false)
    api.nvim_win_set_option(window, 'winhighlight',  'FloatBorder:PopupMenuFloatBorder,NormalFloat:PopupMenuText')
    
    -- ハイライト用のnamespace, extmarkを作成
    local ns_id = api.nvim_create_namespace("popup_menu_ns")
    local current_extmark = nil

    -- 選択カーソル表示(cursor_posの位置にハイライト)
    ---@param cursor_pos number 
    ---@param buffer number
    local function window_update(cursor_pos, buffer)
		if current_extmark then
		    api.nvim_buf_del_extmark(buffer, ns_id, current_extmark)
		end

		current_extmark = api.nvim_buf_set_extmark(buffer, ns_id, cursor_pos - 1, 0, {
		    end_row = cursor_pos,
		    hl_group = "PopupMenuTextSelected",
		    priority = 100,
			hl_eol = true,
		})
    end

    -- popup_menu描画関数
    local function popup_menu_render()
		-- スクロール表示部分をdisplay_tableに格納
		local end_index = math.min(start_index + height - 1, #popup_table)
		local display_table = {}

		for i = start_index, end_index do
		    table.insert(display_table, popup_table[i])
		end

		-- popup_windowを更新
		api.nvim_buf_set_lines(buffer, 0, -1, true, display_table)
		window_update(cursor_pos, buffer)
    end

    -- cursor制御関数(スクロール処理も行う)
    ---@param direction number
    local function popup_menu_move_cursor(direction)
		if direction == "down" then
		    if cursor_pos < height and (start_index + cursor_pos - 1) < #popup_table then
			cursor_pos = cursor_pos + 1
			
		    elseif (start_index + height - 1) < #popup_table then
				start_index = start_index + 1
		    end
		elseif direction == "up" then
		    if cursor_pos > 1 then
				cursor_pos = cursor_pos - 1
		    elseif start_index > 1 then
				start_index = start_index - 1
		    end
		end
		popup_menu_render()
    end

    --選択した要素をcallbackで返す
    local function popup_menu_select()
		local select_index = start_index + cursor_pos - 1
		
		if popup_table[select_index] then
		    api.nvim_win_close(window, false)
		    if callback then
				callback(popup_table[select_index])
		    end
		end
    end

    -- キーマッピング
    vim.tbl_map(function(buf)
		vim.keymap.set('n', '<ESC>', function()
		    api.nvim_win_close(window, false)
		end, { buffer = buf })

		vim.keymap.set('n', '<Down>', function() popup_menu_move_cursor("down") end, { buffer = buf })
		vim.keymap.set('n', '<Up>', function() popup_menu_move_cursor("up") end, { buffer = buf })
		vim.keymap.set('n', '<CR>', function() popup_menu_select() end, { buffer = buf })
    end, { buffer })

    -- 初期表示
    popup_menu_render()
end

return M

コードの説明

以下、コードの部分について説明です。

local M = {}
-- 中略
function M.popup_menu(popup_table, opt, callback)
--中略
return M

これは公開関数を作成している部分です。Luaではテーブルに関数を入れておくことで外部に公開する関数として扱えます。

local ns_id = api.nvim_create_namespace("popup_menu_ns")
local current_extmark = nil

local function window_update(cursor_pos, buffer)
	if current_extmark then
		api.nvim_buf_del_extmark(buffer, ns_id, current_extmark)
	end

	current_extmark = api.nvim_buf_set_extmark(buffer, ns_id, cursor_pos - 1, 0, {
		end_row = cursor_pos,
		hl_group = "PopupMenuTextSelected",
		priority = 100,
		hl_eol = true,
	})
end

これは選択した項目のみハイライトを変更する処理です。ここではextmark機能を使用しています。

extmark機能とは指定した範囲のマーカー機能で、hl_groupにハイライトを指定することで、指定範囲のハイライトのみを変更することができます。

また、namespace機能でハイライトを管理しやすくまとめています。

vim.tbl_map(function(buf)
	vim.keymap.set('n', '<ESC>', function()
		api.nvim_win_close(window, false)
	end, { buffer = buf })

	vim.keymap.set('n', '<Down>', function() 
		popup_menu_move_cursor("down") 
	end, { buffer = buf })
	
	vim.keymap.set('n', '<Up>', function() 
		popup_menu_move_cursor("up") 
	end, { buffer = buf })
	
	vim.keymap.set('n', '<CR>', function() 
		popup_menu_select()
	end, { buffer = buf })
end, { buffer })

この箇所はキーマップを指定しているところです。
vim.tbl_map(func, table)で囲わずともいちいちvim.keymap.set()で指定できますが、処理のかたまりとしてみやすいようにmapにしています。

また、vim.keymap.set()のcallbackに直接関数を指定できるので、function() ~ endで囲わずともpopup_menu_select()を呼び出すことは可能です。

vim.keymap.set('n', '<CR>', popup_menu_select, { buffer = buf })

ただし、引数をcallbackに渡す場合は直接指定できないため、function() ~ endで無名関数に包むか関数を返すクロージャを作ってcallbackに指定する必要があります。

結果

以上のソースを~/.config/nvim/lua/utils下などに置き、以下のように使用します。

local popup_menu = require("utils.popup_menu")

local table = { "Item1", "Item2", "Item3" }
local opt = {
  start_index = 1,
  relative = "cursor",
  row = 0,
  col = 0,
  width = 40,
  height = 2,
  border = "rounded",
  title = "popup_menu",
  zindex = 1,
}

popup_menu.popup_menu(table, opt, function(result)
  print(result)
end)

以下のようにpopup_menuが表示されます。

表示されていない分はスクロールすることで表示できます。

まとめ

車輪の再発明ではありますが、Floating WindowとLuaに慣れるにはいい教材でした。
また、Floating WindowはVimのときのPopupのような制限がなく、さらにAPI化されており大変便利でした。
今回乗り換えるまで、NeoVimについて少し食わず嫌いしていた節がありましたが、Vimの機能がより洗練された部分が多く、思っていたよりいいものでした。今後も使っていきたいと思います。

Discussion