🕺

mini.nvimの新モジュールmini.keymapで決めろマルチステップコンボ

に公開

この記事はVim駅伝の2025-05-14の記事です。
前回の記事はわたすけさんのNeovim v0.11のネイティブ補完APIをlazy.nvim+Mason 2.0で使うです。

Vim駅伝は常に参加者を募集しています。詳しくはこちらのページをご覧ください。


mini.nvimというNeovimプラグインがあります。モジュールという単位で多様な機能をまとめたプラグインです。

https://github.com/echasnovski/mini.nvim

先日、mainブランチにmini.keymapという機能がマージされました。

https://github.com/echasnovski/mini.nvim/commit/0760ca97066a24879b1b581c8daab46e2aba8847

本記事執筆時点ではまだstableにはなっておらず、ベータ版の状態です。以下のissueでフィードバックを募っています。
https://github.com/echasnovski/mini.nvim/issues/1780

近い内に本リリースになると思われるので、本記事で機能の概要を解説します。

mini.keymapはmultistepcomboの2つの主要な機能を持っています。

map_multistep

map_multistepはfallback mapを簡単に書くことができる機能です。
複数の機能を条件とともに一つのキーにマップし、条件を満たしている機能を実行します。

例として、以下のように設定します。stepsに指定しているのはビルトインステップの名前です。ここでは同じmini.nvimファミリーのmini.snippetsの機能を用いるものを指定していますが、Neovim本体のvim.snippet、プラグインのLuaSnip、nvim-cmp、blink.cmp、nvim-autopairsの機能にも対応しているようです。

init.lua
local map_multistep = require('mini.keymap').map_multistep

local tab_steps = {
  'minisnippets_next', -- スニペットが展開されていれば次の位置へ
  'minisnippets_expand', -- スニペットが展開できるなら展開
  'pmenu_next', -- 補完メニューが展開されていれば次の候補へ
  'jump_after_tsnode', -- 「次のノード」が見つかればジャンプ
  'jump_after_close', -- 閉じ括弧が見つかればその後にジャンプ
}
map_multistep('i', '<tab>', tab_steps)

local shifttab_steps = {
  'minisnippets_prev', -- スニペットが展開されていれば前の位置へ
  'pmenu_prev', -- 補完メニューが展開されていれば前の候補へ
  'jump_before_tsnode', -- 「前のノード」が見つかればジャンプ
  'jump_before_open', -- 開き括弧が見つかればその前にジャンプ
}
map_multistep('i', '<s-tab>', shifttab_steps)

jump_before_tsnodejump_after_closeはtabout.nvimを参考に作られたものらしく、挿入モード内で括弧(など)をまたいだ移動をできる機能です(現状は想定以上にジャンプしてしまうなど、ちょっとバギーな感じはします)。

https://github.com/abecodes/tabout.nvim

また、ステップは自分で定義することもできます。実行条件を判定するconditionと、実際の機能またはエミュレートするキーを返すactionの2つの無引数関数を持つテーブルを定義し、map_multistepに渡します。

筆者は<cr>は以下のように定義しています。

init.lua
local function has_trailing_word_after_completion()
  if vim.fn.complete_info({ 'selected' }).selected == -1 then
    -- complete item is NOT selected
    return false
  end
  local current_line = vim.api.nvim_get_current_line()
  if current_line == '' then
    -- current line is empty
    return false
  end
  local col = vim.fn.getcursorcharpos()[3]
  local char_at_cursor = vim.fn.strcharpart(current_line, col - 1, 1)
  local iskeyword = vim.regex('\\k'):match_str(char_at_cursor)
  return iskeyword ~= nil
end
local function pmenu_accept_and_remove_trailing_word()
  return '<c-y><c-o>"_de'
end

map_multistep('i', '<cr>', {
  {
    -- 条件:補完候補を選択していて、かつ直後にwordが続いていたら
    condition = has_trailing_word_after_completion,
    -- 機能:補完候補を確定しつつ、直後のwordを削除
    action = pmenu_accept_and_remove_trailing_word,
  },
  'pmenu_accept', -- 補完候補が表示されていたら確定
  'minipairs_cr', -- mini.pairsのcrを実行
})

has_trailing_word_after_completionは補完候補選択中にカーソル直後に文字列が続いているときにtrueとなる関数です。pmenu_accept_and_remove_trailing_wordは補完候補を選択した後に直後のwordを削除します。これにより、補完時にバッファ文字列を上書きするような動きができるようになります。<cr>を自分から押さなければ上書きは実行されないので、普通に入力していて邪魔になることもなく、たまに便利です。


pmenu_accept_and_remove_trailing_wordの動作例

map_combo

map_comboは短時間に一定のキーが押された場合のみ有効になるキーマップが書けます。複数キーの(ほぼ)同時押しや、同キーの連打によって発動するキーマップを作成できます。長くVim界隈にいる方には、vim-arpeggioのようなものというとわかりやすいかもしれません(とはいえmap_comboは内部的にvim.on_keyを使用していて、実装は大きく異なりますが)。

https://github.com/kana/vim-arpeggio

この動作自体は、better-escape.nvimを参考にしたもののようです。

https://github.com/max397574/better-escape.nvim

「単語移動のwbを連打すると段落移動になる」という例を考えてみましょう。素朴に実装すると以下のようになりますが、これでは1回目のwで2キー目の待機が入ってしまいます。

init.lua(待機が入ってしまう例)
vim.keymap.set({ 'n', 'x' }, 'ww', '}', {})
vim.keymap.set({ 'n', 'x' }, 'bb', '{', {})

map_combo()を使うと待機が入らず、キー単体の機能を保ったまま、連打機能を追加することができます。ただし、いったん単体の機能も実行される(1回目に押したときには単語単位での移動も行われる)ことには注意です。

init.lua
local map_combo = require('mini.keymap').map_combo

map_combo({ 'n', 'x' }, 'ww', '}')
map_combo({ 'n', 'x' }, 'bb', '{')

以下は<esc>の2連打で<c-l>を用いた画面のクリーンアップを行う例です。Neovimのデフォルトの拡張(:h CTRL-L-default)と、vim._extuiのウィンドウの消去も行います。

init.lua
map_combo({ 'n', 'i', 'x', 'c' }, '<esc><esc>', function()
  local ok, extui_shared = pcall(require, 'vim._extui.shared')
  if ok then
    local extuiwins = extui_shared.wins[vim.api.nvim_get_current_tabpage()]
    vim.api.nvim_win_set_config(extuiwins.box, { hide = true })
  end
  vim.cmd.nohlsearch()
  vim.cmd.diffupdate()
  return '<c-l>'
end)

extuiの消去は以下の記事を参考にしました。

https://blog.atusy.net/2025/05/13/nvim-extui-msgbox-closer/

そもそもextuiがなんなのか知りたい方は以下の記事へどうぞ。

https://zenn.dev/kawarimidoll/articles/4da7458c102c1f

以下はjkの同時押しで挿入モードまたはコマンドラインモードから抜ける例です。実際はjkまたはkjの短時間の連打なのですが、これら両方の順序を定義しておくことで、同時押しにマッピングしたかのように使えます。jkがいったん入力されてしまうため、<bs>2回で削除しています。

init.lua
map_combo({ 'i', 'c' }, 'jk', '<bs><bs><esc>')
map_combo({ 'i', 'c' }, 'kj', '<bs><bs><esc>')

ヘルプには以下のように「同じキーを押し続けたら警告する」という例も載っています。アイデア次第でいろいろと機能を作れそうですね。

init.lua
local notify_many_keys = function(key)
  local lhs = string.rep(key, 5)
  local action = function() vim.notify('Too many ' .. key) end
  map_combo({ 'n', 'x' }, lhs, action)
end
notify_many_keys('h')
notify_many_keys('j')
notify_many_keys('k')
notify_many_keys('l')

まとめ

multistepcomboも自作しようとすると面倒だったのですが、このモジュールで簡単に設定できるようになりました。「コンテキストをもとに特定の条件下でのみ機能が発動する」「キーを同時に押したときに機能が発動する」など、これまでとは違うマッピングの可能性を秘めた機能だと思います。
筆者もどんなマッピングが有効なのか試行錯誤中です。もし面白い設定を作れたら教えていただけると嬉しいです。

mini.nvimのほかのモジュールは以下の本にまとめているので、併せてご覧ください。

https://zenn.dev/kawarimidoll/books/6064bf6f193b51

Discussion