⌨️

nvim-submode : 直感的に設定可能な新しいサブモードプラグイン

に公開

nvim-submodeはLuaで書かれた直感的ながら強力な機能を持つサブモードプラグインです。どのくらい強力かというと、
わずか8行でCAPSLOCKを実装できたり、

たった20行でclever-f.vimの簡易版(SMART-F)が実装できたりします。

https://github.com/sirasagi62/nvim-submode

What is サブモード

そもそもサブモードとは何でしょうか?一言で言えば「ユーザー独自のモードを作成できる機能」のことです。これの何が嬉しいかというと複雑なキーバインドの入力や繰り返しを大幅に簡略化できる点です。この手の話でよく出てくるのがウィンドウ操作です。Vim/Neovimでは画面を分割して、水平タブや垂直タブのような画面を作り出すことができ、これをwindowと呼ぶのですが、デフォルトのキーバインドにおいてwindowに関する操作は<c-w>というプリフィックスをつけて操作します。例えば<c-w>wで次のウィンドウ、<c-w>+で高さ変更といった塩梅です。ですが、特に高さ操作などで顕著ですが、これらの操作は非常に冗長です。特に高さを数行変更するたびに<c-w>+を連打するのも苦痛に感じられます。

この問題を解消するのがサブモードです。サブモードでは仮想的なモードを設定し、そのモード上で簡潔なキーバインドを設定することで冗長な操作の繰り返しを排除できます。
例えば、

<c-w>(normal) → ノーマルモードからウィンドウモードへの移行
+(window) → ウィンドウモードで高さ変更
w(window) →  ウィンドウモードで次のウィンドウに移動
<c-w>(window) → ウィンドウモードを終了

と設定できれば、「次のウィンドウに移動して5行分高さ変更」という操作を

<c-w>w+++++<c-w> 

という風に行えます。デフォルトだと

<c-w>w<c-w>+<c-w>+<c-w>+<c-w>+<c-w>+

となるので、かなり簡潔になっていますね。

この手のプラグインというのはかなり前から存在していて、忍者、ラーメンに並ぶ日本の国宝と並び称されるkana/vim-submodeを始めhydra.nvimなど様々なプラグインが存在しています。特にvim-submodeはサブモードという概念そのものの先駆けであり、15年以上前のプラグインでありながら未だに根強い人気を持ちます(最近でもredditでスレが立っている)。

nvim-submodeの強み

では、こうした既存のサブモードプラグインと比較するとnvim-submodeはどのような点が異なるのでしょうか。vim-submodeと比較した機能面の違いは主に3つです

  • 任意のキーにマッチングする<any>の導入
  • 数字による実行の繰り返しが可能(オフにすることもできて、その場合では数字もキーとみなして割り当てられる)
  • Luaで設定が書ける

特に強力なのが1つ目と2つ目になります。

<any>キーマッチング

サブモードを実装していると任意のキーをトリガーにしたい場合があります。例えば

  • f<any><any>を行内検索する
  • CAPSLOCKモードを作って任意のアルファベットを大文字にしたい
    みたいな需要です。しかし、vim-submodeはvimのキーマッピングシステムをベースとしているため「任意のキーにマッチするマッピング」というのを宣言的に書くことが困難です。同様の機能を実現するためには
  • 全アルファベットに対応するキーを列挙する力技
  • getchar()を駆使して無理やり文字を取得する

などの工夫が必要になります。しかし、前者は面倒ですし、後者はNeovimの制御を奪うためその間一切の処理が実行されなく鳴ってしまいます。

一方、nvim-submodeはこれを実現するための特殊キー<any>を提供しています。これにより、宣言的な記述で任意のキーを受け取ることができます。また、vim.on_keyをベースにすることでイベント駆動で処理が実行されるため、Neovimの処理を止めることもありません。

<any>の強さを確認するためにCAPSLOCKモードを実装してみましょう。

local sm = require("nvim-submode")
local capslock_sm = sm.build_submode(
  { name = "CAPSLOCK", is_count_enable = false },
  {{'<any>',
    function(count, keys, anys)
      return string.upper(sm.replace_any(keys, anys))
    end
  }}
)
vim.keymap.set("i", "<C-l>", function()
  sm.enable(capslock_sm)
end)

サブモード本体の機能がわずか8行で実装されていることがわかります。やっていることも

  • CAPSLOCKという名前にする
  • 任意のキーを受け取る
  • 受け取った文字列の<any>を実際に受け取った文字列に置き換える
  • それを大文字にして返す

というだけです。非常に直感的だと思います。これだけ記述して<C-l>を押せばすべてのアルファベットが大文字になります。モードから抜けたいときは<Esc>を押すだけです。簡単ですね。

今さらっと流してしまいましたが、submodeの構築にはsm.build_submodeという関数を使います。2つのテーブルを引数として、1つ目の引数にはサブモードのメタデータを受け取り、もう1つには実際のキーマッピングを受け取ります。メタデータには名前の他にサブモードのテーマカラー、サブモードに出入りするときのコールバックなどが設定できます。

キーマッピングは配列で、各要素に実際のマッピングを受け取ります。それぞれのマッピングは

{
   "<lhs>","<rhs>" or rhs_callback,{opts(nullable)}
}

という構成になっています。キーマッピングの方は配列である点と第一要素からモード名がないことを除けばvim.keymap.setとほとんど同じ(ただしoptsは今のところ機能しない)なのでそんなに迷うことはないと思います。

せっかくなので記事冒頭で見せたCAPSLOCKサブモードの実装もお見せしたいと思います。主な違いとして

  • 後述するlualineの設定を駆使してlualineのモード名にサブモード名を表示する
    • そのためにカラーとコールバックを設定する
  • <C-l>でサブモードを終了するようになっている
  • コールバックで開始と終了時に通知を流している

という点が挙げられます。

local capslock_sm = sm.build_submode({
  name = "CAPSLOCK",
  color = "#e0af68", -- 実際にはtokyonightをimportしてcolors.yellowを指定
  is_count_enable = false,
  after_enter = function() 
    vim.schedule(function()
      require("lualine").refresh() -- scheduleでrefreshしないと反映されないことがある
    end)
    vim.notify("ENTER CAPSLOCK")
  end,
  after_leave = function()
    vim.schedule(function()
      require("lualine").refresh()
    end)
    vim.notify("EXIT CAPSLOCK")
  end
}, {
  {
    '<any>',
    function(count, keys, anys)
      return string.upper(sm.replace_any(keys, anys))
    end
  },
  {
    '<C-L>',
    function(_, _, _)
      return "", sm.EXIT_SUBMODE
    end
  }
})

vim.keymap.set("i", "<C-L>", function()
  sm.enable(capslock_sm)
end)

rhsとなるコールバックは実際には返り値を2つ返すことができ、最初の返り値が実際に入力される文字列となりますが、2つめの返り値でsm.EXIT_SUBMODEを設定するとrhsを実行後にサブモードを終了することができます。ちなみに設定無し、すなわちnilを返却するとモード継続です。nilとsm.EXIT_SUBMODE以外は設定できません。

これだけ実装して全体でおよそ30行で収まっています。<any>のパワフルさが実感できる例だと思います。

数字による実行の繰り返し

先程のCAPSLOCKサブモードでrhsのコールバックがcountという引数を受け取っていることからもわかるように、nvim-submodeはVim/Neovimのv:countに相当する機能をサポートしています。

カウントすると言えばFizzBuzzですね!というわけで、サブモードでFizzBuzzを実装します。

local fizzbuzz_keymap = {
  {
    'f',
    function(count, _, _)
      count = count > 0 and count or 1
      local fb = ""
      for i = 1, count, 1 do
        if i % 15 == 0 then
          fb = fb .. 'FizzBuzz' .. '\n'
        elseif i % 3 == 0 then
          fb = fb .. 'Fizz' .. '\n'
        elseif i % 5 == 0 then
          fb = fb .. 'Buzz' .. '\n'
        else
          fb = fb .. tostring(i) .. '\n'
        end
      end
      return fb
    end
  }
}

vim.keymap.set('i', '<C-f>', function()
  sm.enable(sm.build_submode({
    name = "FIZZBUZZ",
    color = "#7dcfff",
    after_enter = function()
      vim.schedule(function()
        require("lualine").refresh()
      end)
    end,
    after_leave = function()
      vim.schedule(function()
        require("lualine").refresh()
      end)
      vim.notify("EXIT FIZZBUZZ")
    end
  }, fizzbuzz_keymap))
end)

キーマップではいいキーが思いつかなかったので<C-f>を設定していますが、もちろんなんでも良いです。キー入力の記録を取っていないため(仕組み上keycastrと併用できないため)分かりづらいですが、fと押すと1までのFizzBuzzが15fと押すと15までのFizzBuzzが入力されます。v:countと同じように数字が扱えるようになるわけです。

キーバインドの仕組み

キーバインドのrhsでは文字列もしくはコールバックを受け取れます。文字列の場合はそれがそのまま入力されます。次のようにすると「すべてがFになるサブモード」を作れます(ちなみに元ネタは読んだことないです)。もちろんlhsは普通のキーでも問題ないです。<C-A>などのキーバインドも<ESC>以外使えます。

local perfect_insider_sm = sm.build_submode({ name = "PERFECT INSIDER" },
  {
    { '<any>', 'F' },
  }
)

vim.keymap.set("i", "<C-S-f>", function()
  sm.enable(perfect_insider_sm)
end)

メタデータのis_count_enableがtrueになっている場合(デフォルトでtrue)、キーバインドの直前の数字に応じてキー入力が繰り返されます。15fと入力した場合fを15回入力したのと同じになり対応するキーバインドが15回分入力されます。一方is_count_enableがfalseになっている場合は数字もキーとして解釈されるため、15fと入力した場合1,5,fと入力されたと解釈して扱われます。is_count_enableがtrueのときは<any>でも数字を入力として解釈できないので注意してください。

一方、rhsにコールバックを指定した場合は引数としてcount,keys,anysの3つを受け取ります。それぞれの役割は

  • count: 何回操作を繰り返すべきか。v:countに同じ
  • keys: どのlhsによってコールバックが呼び出されたか。<any>も含む
  • anys: それぞれの<any>には実際に何の文字が入力されたかを示すstring配列

です。例えば

{ "v<any><any>" , function(count,keys,anys) end }

のようなキーバインドがあり、15vimと入力すると

count = 15
keys = "v<any><any>"
anys = {"i","m"}

となります。ただ、このようなキーバインドでは多くの場合vimという入力が欲しかったりします。そこで、それを導出するためのヘルパー関数としてsm.replace_any(keys, anys)が用意されています。これを適用するとvimというテキストが返り値として受け取れます。

コールバックを使うときの注意点として、文字列とは異なり自動でリピートされないという点が挙げられます。先程の例では15vimと入力したとしてもcallbackの呼び出しは1回になります。
これはコールバックをrhsとする場合、デフォルトで繰り返し処理を適用してしまうと、関数内で独自に繰り返しを適用したい場合に扱いづらくなってしまうためです。しかし、一貫性には欠けます。

そこで、繰り返しを適用したいコールバックについては

{ "v<any><any>" , sm.countable(function(count,keys,anys) end) }

のようにsm.countableで包むことで繰り返しが適用されるようになります。このとき、各回の呼び出しにはすべてデフォルト動作と同様の引数が渡されます。また、返り値としては最後の実行の返り値が返されます。

lualineとの設定

lualineの設定を変更し、submodeのコールバックでステータスラインの更新をトリガーすることで現在のサブモードを表示できるようになります。どのモードに入っているかわかりやすくなりますし、見た目的にもインパクトが大きいです。lualine以外のステータスラインプラグインでも近い方法で設定できると思います。

lualineを始めとしたステータスラインは以下のような仕組みになっています。

  1. statuslineに関数を使ったステータスラインを登録する
  2. モードが変更されるたびにステータスラインを再描画して、その時の関数の値をもとにステータスラインを作成する

したがって、ステータスラインにサブモードの情報を表示するには、現在のサブモード名とサブモードのテーマカラーを取得する必要があります。これらを得るためにnvim-submodeではsm.get_submode_name()sm.get_submode_color()を提供しています。

以上を組み合わせて、以下のような設定を作るとコールバックでトリガーするだけで自動で色とモード名が設定されます。ちなみにnvim-submodeでは色が未設定の場合には#999999にフォールバックされます。

lualineの設定例
local sm = require('nvim-submode')

local function submodeNameLualine()
  return sm.get_submode_name() or get_mode()
end

local function submodeNameLualineWithBaseMode()
  local submode = sm.get_submode_name()
  if submode then
    return submode .. '(' .. get_mode() .. ')'
  else
    return get_mode()
  end
end

local function submodeLualineBGColor()
  local color = sm.get_submode_color()
  return color and { bg = color } or nil
end

local function submodeLualineFGColor()
  local color = sm.get_submode_color()
  return color and { fg = color } or nil
end


local colored_submode_component = {
  {
    submodeNameLualine,
    color = submodeLualineBGColor,
    separator = { left = '', right = '' },
  }
}

local branch_with_submode = {
  {
    'branch',
    icon = { '' },
    color = submodeLualineFGColor
  }
}

-- config for lualine
local lualine = require('lualine')
lualine.setup {
  options = {
    globalstatus = true,
    theme = 'tokyonight',
    section_separators = { left = '', right = '' },
  },
  sections = {
    lualine_a = colored_submode_component,
    lualine_b = branch_with_submode,
    lualine_c = { 'diff', 'diagnostics', 'filename' },
    lualine_y = {},
    lualine_z = {}
  }
}

clever-f.vimに似たsmart-fサブモード

以上の機能の集大成的なサブモードが冒頭で見せたclever-f.vimみたいな動作をするsmart-fサブモードです。

ちなみにclever-f.vimというのはfによる行内検索を便利にするプラグインで、f<一文字>で検索する動作はそのままにf/Fを押すとそれぞれ前後の一致する文字に移動できる(つまり;/,と同じになる)という便利なプラグインです。
https://github.com/rhysd/clever-f.vim

f/Fだけで検索と移動ができる、という意味でも、;,,に別の動作を割り当てられるという点でも非常に便利だと思います。

ノーマルモードで;,,を別のタスクに割り当てつつ、f/F,;/,でサブモード内は進むことができます。f<any>によって任意のキーを取得してその文字を行内検索することも可能です。count機能を用いることで回数分だけ進むこともできます。行をまたいだ検索ができなかったりするなど制約も多いですが、複雑なプラグインに近い機能をたった20行で宣言的に記述することができます。nvim-submodeの強みを最大限活かした例になっていると思います。

f/f<any>のように1文字を入力した時点で確定できないキーバインドはtimeoutlenms後までに後続のキーが入力されない場合、短い方で確定します。timeoutlenはメタデータで設定もできますし、デフォルトではvim.o.timeoutlenを読むようになっています。

sm.enableの第二引数には「モードに入った時点であらかじめ入力されているキー」を指定できます。これによって、例のようなマッピングでもfaとノーマルモードで打った時点で最寄りのaにジャンプでき、自然な挙動になります。

local sm_smart_f = sm.build_submode({
  name = "SMART-F",
  timeoutlen = 300,
  color = colors.purple,
  after_enter = function()
    vim.schedule(function()
      require("lualine").refresh()
    end)
  end,
  after_leave = function()
    vim.schedule(function()
      require("lualine").refresh()
    end)
    vim.notify("EXIT SMART-F")
  end
}, smart_f_keymap)
vim.keymap.set('n', 'f', function()
  sm.enable(sm_smart_f, 'f')
end)
vim.keymap.set('n', 'F', function()
  sm.enable(sm_smart_f, 'F')
end)

まとめ

本記事では新しいLua製サブモードプラグイン、nvim-submodeを紹介しました。今回紹介できた例はそこまで多くないのですが、様々に拡張できることがわかったと思います。これ以外にも、漢字直接入力をサブモードで実装する、定番のウィンドウサブモードを定義する、ネタサブモードを作る、など様々な応用が効くと思います。機能自体はシンプルですが、非常にパワフルで設定しがいのあるプラグインに仕上がったと思うので、ぜひ皆さんも思い思いのサブモードを設定してみてください。

Discussion