当初,Neovim 刚刚推出异步 job 特性的时候,我就使用 Vim Script 写过一个实时检索的插件 FlyGrep.vim。
最开始的实现是使用 :split
命令分屏展示搜索结果,使用 :echo
命令配合 while true getchar()
在 cmdline 内模拟输入框。
但是 :split
命令分屏时,总是带动整个界面其他窗口内容的移动。随着 Neovim 增加悬浮窗口这一特性,
我把 Flygrep 搜索结果窗口及底部提示状态都使用浮窗来实现,这就不会受到原先窗口界面布局的影响了。但是目前 Flygrep 的浮窗还是底部半屏窗口。
现在大多数浮窗插件都是在屏幕中间打开窗口,下面就从零开始一步一步实现一个简单的实时代码检索插件。
最终效果图如下:
窗口界面
首先时窗口界面,整体界面占据窗口中间 80% 区域,分上下两部分,下面窗口仅有一行,作为一个输入窗口,上面窗口作为搜索结果展示窗口。
-- 窗口位置
-- 宽度: columns 的 80%
local screen_width = math.floor(vim.o.columns * 0.8)
-- 起始位位置: lines * 10%, columns * 10%
local start_col = math.floor(vim.o.columns * 0.1)
local start_row = math.floor(vim.o.lines * 0.1)
-- 整体高度:lines 的 80%
local screen_height = math.floor(vim.o.lines * 0.8)
local prompt_bufid = vim.api.nvim_create_buf(false, true)
local prompt_winid = vim.api.nvim_open_win(prompt_bufid, true, {
relative = 'editor',
width = screen_width,
height = 1,
col = start_col,
row = start_row + screen_height - 3,
focusable = true,
border = 'rounded',
title = 'Input',
title_pos = 'center',
-- noautocmd = true,
})
local result_bufid = vim.api.nvim_create_buf(false, true)
local result_winid = vim.api.nvim_open_win(result_bufid, false, {
relative = 'editor',
width = screen_width,
height = screen_height - 5,
col = start_col,
row = start_row,
focusable = false,
border = 'rounded',
title = 'Result',
title_pos = 'center',
-- noautocmd = true,
})
输入响应
在底部窗口输入内容时,后台自动执行搜索命令,并在搜索结果窗口实时展示。这里需要监控 TextChangeI
这一事件,在事件 callback 函数内调用搜索命令。
local job = require('spacevim.api.job')
local augroup = vim.api.nvim_create_augroup('floatgrep', {
clear = true,
})
vim.api.nvim_create_autocmd({ 'TextChangedI' }, {
group = augroup,
buffer = prompt_bufid,
callback = function(ev)
local text = vim.api.nvim_buf_get_lines(prompt_bufid, 0, 1, false)[1]
if text ~= '' then
local grep_cmd = {
'rg',
'--no-heading',
'--color=never',
'--with-filename',
'--line-number',
'--column',
'-g',
'!.git',
'-e',
text,
'.',
}
job.start(grep_cmd, {
on_stdout = function(id, data)
if vim.fn.getbufline(result_bufid, 1)[1] == '' then
vim.api.nvim_buf_set_lines(result_bufid, 0, -1, false, data)
else
vim.api.nvim_buf_set_lines(result_bufid, -1, -1, false, data)
end
end,
})
else
vim.api.nvim_buf_set_lines(result_bufid, 0, -1, false, {})
end
end,
})
上述代码中,我使用了 spacevim job API,其实,我也考虑过使用 vim.system()
函数,但是异步搜索完全不调用,
可能是写法有误, 使用 vim.system()
写法如下(无效):
vim.api.nvim_create_autocmd({ 'TextChangedI' }, {
group = augroup,
buffer = prompt_bufid,
callback = function(ev)
local text = vim.api.nvim_buf_get_lines(prompt_bufid, 0, 1, false)[1]
if text ~= '' then
local grep_cmd = {
'rg',
'--no-heading',
'--color=never',
'--with-filename',
'--line-number',
'--column',
'-g',
'!.git',
'-e',
text,
'.',
}
vim.system(grep_cmd, {
stdout = function(err, data)
vim.api.nvim_buf_set_lines(result_bufid, 0, -1, false, vim.split(data, '\n'))
end,
})
end
end,
})
按键映射
因为输入框只有一行,因此避免回车键换行,同时增加在搜索结果窗口内上下移动两个快捷键。
-- 使用 Esc 关闭整个界面
vim.keymap.set('i', '<Esc>', function()
vim.cmd('noautocmd stopinsert')
vim.api.nvim_win_close(prompt_winid, true)
vim.api.nvim_win_close(result_winid, true)
end, { buffer = prompt_bufid })
-- 搜索结果行转换成文件名、光标位置
local function get_file_pos(line)
local filename = vim.fn.fnameescape(vim.fn.split(line, [[:\d\+:]])[1])
local linenr =
vim.fn.str2nr(string.sub(vim.fn.matchstr(line, [[:\d\+:]]), 2, -2))
local colum = vim.fn.str2nr(
string.sub(vim.fn.matchstr(line, [[\(:\d\+\)\@<=:\d\+:]]), 2, -2)
)
return filename, linenr, colum
end
-- 使用回车键打开光标所在的搜索结果,同时关闭界面
vim.keymap.set('i', '<Enter>', function()
vim.cmd('noautocmd stopinsert')
-- 获取搜索结果光表行
local line_number = vim.api.nvim_win_get_cursor(result_winid)[1]
local filename, linenr, colum = get_file_pos(
vim.api.nvim_buf_get_lines(
result_bufid,
line_number - 1,
line_number,
false
)[1]
)
vim.api.nvim_win_close(prompt_winid, true)
vim.api.nvim_win_close(result_winid, true)
vim.cmd('edit ' .. filename)
vim.api.nvim_win_set_cursor(0, { linenr, colum })
end, { buffer = prompt_bufid })
-- 使用 Tab/Shift-Tab 上下移动搜素结果
vim.keymap.set('i', '<Tab>', function()
local line_number = vim.api.nvim_win_get_cursor(result_winid)[1]
vim.api.nvim_win_set_cursor(result_winid, { line_number + 1, 0 })
end, { buffer = prompt_bufid })
vim.keymap.set('i', '<S-Tab>', function()
local line_number = vim.api.nvim_win_get_cursor(result_winid)[1]
vim.api.nvim_win_set_cursor(result_winid, { line_number - 1, 0 })
end, { buffer = prompt_bufid })
窗口内颜色高亮
-- 高亮文件名及位置
vim.fn.matchadd(
'Comment',
[[\([A-Z]:\)\?[^:]*:\d\+:\(\d\+:\)\?]],
10,
-1,
{ window = result_winid }
)
输入框美化
可以使用 extmarks 美化输入框,添加一个 >
符号,禁用行号。
vim.api.nvim_set_option_value('number', false, { win = prompt_winid })
vim.api.nvim_set_option_value('relativenumber', false, { win = prompt_winid })
local extns = vim.api.nvim_create_namespace('floatgrep_ext')
vim.api.nvim_buf_set_extmark(prompt_bufid, extns, 0, 0, {
sign_text = '>',
sign_hl_group = 'Error',
})
以上代码仅供参考,实际上还有很多细节并未完全考虑到, 比如 callback 函数内对于未执行完成的 job 的处理、 比如增加输入与搜索之间的延迟减少快速输入过程中不必要的额外执行搜索命令。
完整的代码可以看:simple_float_grep