Eric's Blog 时光荏苒,岁月如梭

从零开始制作实时搜索插件

2025-01-23
Eric Wong

当初,Neovim 刚刚推出异步 job 特性的时候,我就使用 Vim Script 写过一个实时检索的插件 FlyGrep.vim。 最开始的实现是使用 :split 命令分屏展示搜索结果,使用 :echo 命令配合 while true getchar() 在 cmdline 内模拟输入框。

但是 :split 命令分屏时,总是带动整个界面其他窗口内容的移动。随着 Neovim 增加悬浮窗口这一特性, 我把 Flygrep 搜索结果窗口及底部提示状态都使用浮窗来实现,这就不会受到原先窗口界面布局的影响了。但是目前 Flygrep 的浮窗还是底部半屏窗口。

现在大多数浮窗插件都是在屏幕中间打开窗口,下面就从零开始一步一步实现一个简单的实时代码检索插件。

最终效果图如下:

float grep window

窗口界面

首先时窗口界面,整体界面占据窗口中间 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


版权声明:本文为原创文章,遵循 署名-非商业性使用-禁止演绎 4.0 国际 (CC BY-NC-ND 4.0)版权协议,转载请附上原文出处链接和本声明。


延生阅读

分享到:

评论

目前只支持使用邮件参与评论。