この記事は Vim Advent calendar 2015 の16日目の記事です.
僕は普段「だ・である」調でブログを書いていますが,今回の記事は多数の人に見ていただくことを考慮して,多くの人がブログで採用している「です・ます」調,語り口調で書いていくことにしましょう.
さて本題です. 候補を絞り込むインターフェースというのは人気で,Vimではunite.vim,ctrlp.vimなどが有名ですね. そして,コマンドラインツールfzfはVimからも利用できるように,本体にVimプラグインが付属しています. また,ctrlp.vimにインスパイアされて開発されたプラグインとして,LeafCageさんによるalti.vimやkamichiduさんによるvim-milqiがあります. この記事では,前述の5つの候補絞り込み型インタフェースを提供するVimプラグイン
について,簡単な解説と比較をしたいと思います. FuzzyFinderやvim-kuについては取り上げません.
基本操作の比較
unite.vim
unite.vimでは,本体にたくさんの拡張が付属しており,第三者(特に日本人に多い)によって開発された拡張も多数公開されているので,自分で拡張を書く必要は無いですね.
基本的に, :Unite [unite source名]
とすることで,unite.vimを起動できます.
unite.vim上でのキーマッピングは以下のようになっています. unite.vimは他の候補絞り込み型プラグインと異なり,ノーマルモードとインサートモードがあり,それぞれのモードで操作が異なります.
キーマッピング
ノーマルモード
キー | 動作 |
---|---|
i / I |
インサートモードに |
a |
候補選択時はアクションを選択,そうでなければ A と同じ |
A |
入力欄の末尾にカーソル移動をしてインサートモード |
q or <C-g> |
unite.vim を終了し,1つ前のuntieバッファメニューを復元する |
Q or g<C-g> |
unite.vim を終了する |
<C-r> |
uniteを再起動する |
<Space> / <S-Space> |
候補の選択状態をトグル(複数選択できるsourceのみ) |
* |
全ての候補選択をトグルする(選択状態が反転する) |
M |
表示候補数を制限する |
<Tab> |
アクションを選択する |
<C-p> / <C-n> |
sourceを切り替える |
<C-a> / <C-k> |
ログ / 候補を echo する |
<C-l> |
再描画する |
<C-h> |
1つ前のパスを削除する |
gg or <C-Home> |
カーソルを1番上に |
G or <C-End> |
カーソルを1番下に |
j or <Down> |
カーソルを1つ下に |
k or <Up> |
カーソルを1つ上に |
J / K |
マッチしない候補をスキップしてカーソルを上 / 下に移動 |
g? |
簡単なuniteのキーマッピングのヘルプを表示 |
N |
候補を追加する(action_table に unite__new_candidate が定義されている場合のみ) |
. |
ドットを絞り込み欄に入力し,インサートモードに |
<CR> |
デフォルトアクションを行う |
b |
ブックマークのアクションを行う |
d |
削除アクションを行う |
e |
narrowアクションを行う |
t |
別のタブで開く |
yy |
ヤンクアクションを行う |
o |
オープンアクションを行う |
x |
クイックマッチを用いて,デフォルトのアクションを行う(マークされた候補があるとダメ) |
インサートモード
キー | 動作 |
---|---|
<Esc> |
ノーマルモードに |
<Tab> |
アクションを選択する |
<C-n> or <Down> |
次の行を選択 |
<C-p> or <Up> |
前の行を選択 |
<C-f> / <C-b> |
前 / 次の行を選択 |
<C-h> or <BS> |
カーソルの1つ前の文字を削除 |
<C-u> |
カーソルの前の入力を全て削除 |
<C-w> |
カーソルの前の単語を削除 |
<C-a> or <Home> |
カーソルを先頭に |
<Left> / <Right> |
カーソルを右 / 左に移動 |
<C-l> |
再描画する |
<C-g> |
uniteを終了する |
ビジュアルモード
キー | 動作 |
---|---|
<Space> |
選択された範囲の候補全ての選択状態をトグル |
本体付属のunite source
以下の表は,unite.vim本体から提供されているunite sourceの一部です. デフォルトのアクションについても併記しています.
他にもたくさんのunite sourceが付属しているので,調べてみると面白いと思います.
unite source名 | 機能 |
---|---|
bookmark |
UniteBookmarkAdd でブックマークされたファイルを表示し,選択されたファイルを開く |
buffer |
リストされているバッファ一覧を表示し,選択されたバッファを開く |
change |
ファイルの変更履歴(:change の結果)を表示し,選択された位置に移動 |
command |
利用できるコマンドを表示し,選択されたコマンドをコマンドウィンドウに入力(引数のヒントも表示) |
directory |
カレントディレクトリ以下のディレクトリを表示し,ファイラのように利用できる |
file |
カレントディレクトリ以下のファイルとディレクトリを表示し,ファイラのように利用できる |
file_rec |
カレントディレクトリ以下のファイルを再帰的に検索して表示し,ファイラのように利用できる |
find |
対象ディレクトリと名前を入力させて,findコマンドを実行した結果を表示 |
function |
利用できる関数を表示し,選択された関数をコマンドウィンドウに入力(引数のヒントも提示) |
grep |
外部コマンド grep を行うように促し,grepを行った結果を一覧として表示.選択された位置に移動する |
history/unite |
過去に利用したunite sourceの履歴を表示 |
jump |
ジャンプリスト(:jumps の結果)を表示し,選択された位置に移動 |
launcher |
パスが通っているところから,実行可能ファイルを候補として表示する |
line |
現在のバッファの全ての行を候補として表示し,選択された行に移動 |
mapping |
全てのキーマッピングを表示し,選択されたものを実行する |
output |
Vimコマンドを入力を促し,入力されたコマンドの出力を候補とする |
process |
起動中のプロセス一覧を表示し,選択した候補にTERMシグナルを送る(確認あり) |
register |
Vimのレジスタを表示し,選択されたレジスタの内容を現在位置に挿入 |
runtimepath |
runtimepath に追加されているディレクトリ一覧を表示し,選択されたディレクトリに移動 |
source |
unite sourceの一覧を表示し,選択されたsourceをもとにUniteを起動 |
tab |
現在利用中のVimのタブ一覧(とタブ内のバッファをヒントとして)を表示し,選択されたタブに移動 |
vimgrep |
:vimgrep を行うように促し,vimgrepを行った結果を一覧として表示.選択された位置に移動する |
window |
現在のタブのウィンドウ一覧を表示し,選択されたウィンドウに移動 |
ctrlp.vim
プラグイン名になっているように,デフォルトでは <C-p>
でCtrlPを起動します.
あるいは, :CtrlP
というコマンドで起動してもよいでしょう.
unite.vimと同様,ctrlp.vimの本体からも多数の拡張が提供されており,それぞれの拡張は :CtrlPXXXX
のようなコマンドを実行することで利用可能です.
CtrlPバッファでのキーマッピングは以下のようになっています.
キー | 動作 |
---|---|
<C-c> or <Esc> |
CtrlPを終了する |
<C-f> / <C-b> |
エクステンションを切り替える |
<C-a> / <C-e> |
入力ウィンドウのカーソルを先頭 / 最後に |
<C-j> / <C-k> |
候補ウィンドウのカーソルの上下移動 |
<C-d> |
フルパス検索モードとファイル名のみの検索モードを切り替え |
<C-r> |
正規表現検索モードと通常の検索モードのトグル |
<CR> |
同じタブに開く |
<C-t> |
新しいタブに開く |
<C-v> |
垂直分割で開く |
<C-x> or <C-s> or <C-CR> |
水平分割で開く |
<C-p> |
1つ前の入力履歴を入力欄に入れる |
<C-n> |
1つ後の入力履歴を入力欄に入れる |
<F5> |
キャッシュを更新する |
<F7> |
選択した(マークを付けた)候補を候補から除外する |
<C-z> |
対象ファイルにマークを付ける(選択状態にする) |
また,ctrlp.vimでは,:CtrlP
以外にもコマンドが提供されています.
これらは, autoload/ctrlp.vim
で定義されている組み込みの拡張とも言えるでしょう.
コマンド | 機能 |
---|---|
:CtrlPMRUFiles [dir] |
最近利用したファイルを表示し,選択されたファイルを開く |
:CtrlPBuffer |
リストされているバッファを表示し,選択されたバッファに移動する |
:CtrlPLastMode [args] |
最後に利用したCtrlP拡張を実行する,[args] に --dir が指定されたとき,最後のワーキングディレクトリも用いる |
:CtrlPClearCache or :ClearCtrlPCache |
CtrlPのキャッシュを削除する |
:CtrlPAllClearCache or :ClearAllCtrlPCaches |
CtrlPのキャッシュを全て削除する |
:CtrlPCurWD |
カレントディレクトリ以下のファイルを再帰的に表示 |
:CtrlPCurFile |
現在編集中のファイルのディレクトリ以下のファイルを再帰的に表示 |
:CtrlPRoot |
わからない.... |
先に述べたように,ctrlp.vimの本体からは多数の拡張が提供されています.
先に述べた組み込みの拡張とは違い,多数の拡張は autoload/ctrlp/xxx.vim
で定義されており,本体から分離されております.
拡張名 | 対応するコマンド | 機能 |
---|---|---|
tag |
:CtrlPTag |
見つかったtagsファイル(オプション tags に依存)の項目を表示し,選択された項目の位置へジャンプする |
buffertag |
:CtrlPBugTag [buffer] |
カレントバッファ,または指定されたバッファからtagsファイルの項目を表示し,選択された項目の位置へジャンプする |
buffertag |
:CtrlPBugTagAll |
リストされているバッファ |
quickfix |
:CtrlPQuickfix |
quickfixにある項目を表示し,選択されたエラー位置に移動する |
dir |
:CtrlPDir [dir] |
カレントディレクトリ,または指定されたディレクトリ以下のディレクトリを表示し,選択されたディレクトリをワーキングディレクトリにする |
rtscript |
:CtrlPRTS |
runtimepath 以下にあるファイルを表示し,選択されたファイルを開く |
undo |
:CtrlPUndo |
Undo履歴を表示し,選択されたところまでundoする |
list |
:CtrlPLine |
リストされている全てのバッファ,または指定されたバッファの行を表示し.選択された位置にジャンプする |
changes |
:CtrlPChange [buffer] |
カレントバッファ,または指定されたバッファの変更箇所を表示し,選択された位置にジャンプする |
changes |
:CtrlPChangeAll |
リストされているバッファ全ての変更履歴を表示し,選択された位置にジャンプする |
mixed |
:CtrlPMixed |
カレントディレクトリ以下のファイル,バッファ,最近使用したファイルを表示し,選択されたものを開く |
bookmarkdir |
:CtrlPBookmarkDir |
ブックマークされているディレクトリを表示し, |
bookmarkdir |
:CtrlPBookmarkDirAdd [dir] |
カレントディレクトリ,または指定されたディレクトリをブックマークする(選択後,ブックマークのタイトルの入力が求められる) |
fzf
fzfはコマンドラインツールとして呼び出されるので,Vimとして操作することはできません. 用意されているマッピングは以下のように単純なものです.
キー | 動作 |
---|---|
<C-c> or <C-g> or <Esc> |
fzfを終了する |
<C-f> / <C-b> |
カーソルを移動する |
<CR> |
候補を選択する |
<S-Tab> |
複数選択可能なとき(オプション -m or --multi が指定されているとき),候補にマークを付ける |
本体から拡張は提供されていないので,自分で書くことになるでしょう.
公式のwikiにサンプルが多数ありますので,それらを ~/.vimrc
にコピペなどすればよいかと思います.
alti.vim
alti.vimは グローバル変数 g:alti_default_mappings_base
に 'standard'
か 'ctrlplike'
を代入することで,操作のプリセットを変更することができます.
このグローバル変数のデフォルト値は 'standard'
なので,何もしなかったり,無効な値を入力すると,standardのキーマッピングになるでしょう.
standard
キー | 動作 |
---|---|
<BS> or <C-h> |
入力欄のカーソルの前1文字を削除 |
<Del> or <C-d> |
入力欄のカーソルの後ろ1文字を削除 |
<C-w> |
入力欄のカーソルの前1単語を削除 |
<C-u> |
入力欄をクリアする |
<C-r> |
レジスタ挿入モードに |
<C-x><C-n> or <C-_> |
1つ後の入力履歴を入力欄に入れる |
<C-x><C-p> or <C-s> |
1つ前の入力履歴を入力欄に入れる |
<C-a> / <C-e> |
入力欄の先頭 / 末尾にカーソルを移動 |
<C-b> or <Left> |
入力欄のカーソルを左に |
<C-f> or <Right> |
入力欄のカーソルを右に |
<C-j> or <PageDown> or <kPageDown> |
候補欄で次のページを表示する |
<C-j> or <PageDown> or <kPageDown> |
候補欄で前のページを表示する |
<C-n> or <Down> |
候補欄のカーソルを1つ下に |
<C-p> or <Up> |
候補欄のカーソルを1つ上に |
<C-g>g or <C-g><C-g> or <Home> or <kHome> |
候補窓で先頭にカーソルを移動 |
<C-g>G or <End> or <kEnd> |
候補窓で末尾にカーソルを移動 |
<Tab> |
候補窓のカーソル下の候補を,入力欄に入力する |
<C-o> |
選択候補についてのアクションメニューを出す |
<Esc> or <C-c> |
alti.vimを終了する |
<CR> |
入力(あるいはカーソル下の候補)に基づいて,アクションを行う |
<C-y> |
拡張の定義辞書のキー default_actions に指定されたリストの0番目に指定されたアクションを実行 |
<C-v> |
拡張の定義辞書のキー default_actions に指定されたリストの1番目に指定されたアクションを実行 |
ctrlplike
キー | 動作 |
---|---|
<BS> or <C-]> |
入力欄のカーソルの前1文字を削除 |
<Del> or <C-d> |
入力欄のカーソルの後ろ1文字を削除 |
<C-w> |
入力欄のカーソルの前1単語を削除 |
<C-u> |
入力欄をクリアする |
<C-r> or <C-\> |
レジスタ挿入モードに |
<C-n> |
1つ後の入力履歴を入力欄に入れる |
<C-p> |
1つ前の入力履歴を入力欄に入れる |
<C-a> / <C-e> |
入力欄の先頭 / 末尾にカーソルを移動 |
<C-h> or <Left> |
入力欄のカーソルを左に |
<C-l> or <Right> |
入力欄のカーソルを右に |
<C-f> or <PageDown> or <kPageDown> |
候補欄で次のページを表示する |
<C-b> or <PageDown> or <kPageDown> |
候補欄で前のページを表示する |
<C-j> or <Down> |
候補欄のカーソルを1つ下に |
<C-k> or <Up> |
候補欄のカーソルを1つ上に |
<C-g>g or <C-g><C-g> or <Home> or <kHome> |
候補窓で先頭にカーソルを移動 |
<C-g>G or <End> or <kEnd> |
候補窓で末尾にカーソルを移動 |
<Tab> |
候補窓のカーソル下の候補を,入力欄に入力する |
<C-o> |
選択候補についてのアクションメニューを出す |
<Esc> or <C-c> |
alti.vimを終了する |
<CR> |
入力(あるいはカーソル下の候補)に基づいて,アクションを行う |
<C-y> |
拡張の定義辞書のキー default_actions に指定されたリストの0番目に指定されたアクションを実行 |
<C-v> |
拡張の定義辞書のキー default_actions に指定されたリストの1番目に指定されたアクションを実行 |
このプラグインも本体に拡張は付属していませんが,使い勝手はかなり良いので,拡張を自分で作って利用するとよいでしょう.
vim-milqi
vim-milqiはドキュメントが無いので,ソースコードを読んだ結果を書きます. このあたりを見る限り,ctrlp.vimから持ってきてると推測できるので,ほとんどctrlp.vimと同じ操作であると言ってよいでしょう.
vim-milqiの本体にも拡張は付属していませんが, :MilqiFromUnite
というコマンドが提供されており,引数にunite source名を指定することで,unite sourceから候補を取得し,アクションを実行することができます.
他のプラグインでは提供されていない機能なので,なかなか面白いと思いました.
しかし,unite sourceの候補にkindが指定されていない場合,エラーとなるので注意が必要です.
拡張の作りやすさの比較
各絞り込み検索プラグインの拡張の作りやすさを比較してみましょう. まず,拡張において実現できる特徴的なことを比較すると,以下のようになります.
プラグイン | 複数選択 | 複数のアクションの提供 | 非同期の候補取得 | プロンプトの変更 |
---|---|---|---|---|
unite.vim | ○ | ○ | ○ | ○(起動オプション) |
ctrlp.vim | △ | △(候補選択時のキー) | × | × |
fzf | ○ | × | × | ○ |
alti.vim | ○ | ○ | × | ◎(動的に変更可) |
vim-milqi | × | × | ○ | × |
ここでいう「非同期の候補取得」とは,定期的に候補を取得処理を呼び出し,選択可能な候補数を増やす(あるいは減らすこともできるでしょう)ことです.
Vimでは非同期処理をする手段がちゃんとした形で提供されていないので,非同期の候補の取得は,Shougo/vimproc.vimと, updatetime
と feedkeys()
, そして, CursorHold
, CursorMoved
という autocmd
を組み合わせたポーリングによって行われるのが主流です.
+clientserver
の機能と RemoteReply
を組み合わせても非同期処理は実現できますが,+clientserver
になっているVimは多くないので,こちらが採用されることはありません.
非同期の候補取得でアニメーションを実現するという面白い使い方もあり,例えばsupermomonga/jazzradio.vimでは音量レベルのようなアニメーションを行うunite sourceが提供されています.
さて,同じ目的を達成するそれぞれの拡張を作ってみましょう.
今回は「apple, banana, cakeという3つの候補を表示し,それぞれ選択されたものを echomsg
する」という非常に単純なものを作ることにします.
とにかく,コードを書かないと始まりませんね. 以下,それぞれのコードと解説です. なお,このサンプルの拡張は koturn/vim-exts にあります.
plugin/exts.vim
: インタフェース
if exists('g:loaded_exts') finish endif let g:loaded_exts = 1 let s:save_cpo = &cpo set cpo&vim command! CtrlPExts call ctrlp#init(ctrlp#exts#id()) command! FZFExts call fzf#run(fzf#exts#option()) command! AltiExts call alti#init(alti#exts#define()) command! MilqiExts call milqi#candidate_first(milqi#exts#define()) " command! MilqiExts call milqi#query_first(milqi#exts#define()) let &cpo = s:save_cpo unlet s:save_cpo
インターフェースを定義しているだけなので,特に言及することはありません. uniteだけはコマンド定義しなくて良いので楽ですね. vim-milqiを用いるコマンドについて,コメントアウトしているものがありますが,これについてはvim-milqiの項目で解説します.
autoload/unite/sources/exts.vim
: unite.vimの拡張
let s:save_cpo = &cpo set cpo&vim let s:source = { \ 'name': 'exts', \ 'description': 'descriptions', \ 'action_table': {}, \ 'default_action': 'my_action' \} let s:source.action_table.my_action = { \ 'description': 'my action' \} function! s:source.action_table.my_action.func(candidate) abort echomsg a:candidate.word endfunction function! s:source.gather_candidates(args, context) abort return map(['apple', 'banana', 'cake'], '{ \ "word": v:val \}') endfunction function! unite#sources#exts#define() abort return s:source endfunction let &cpo = s:save_cpo unlet s:save_cpo
unite#sources#exts#define()
でunite sourceに関する辞書を返却するようにします.
今回は word
のみ指定していますが, abbr
や kind
など,他にも重要な項目もありますので, :h unite-notation-{candidate}
で調べてみるとよいでしょう.
この関数はunite.vim本体から呼び出されます.
上記の例では,myaction
というアクションのみを source に対して定義し,<CR>
で選択したときのアクションである default_action
に指定しています.
また,今回はやっていませんが,s:source.action_table.my_action
に is_selectable: 1
を追加すると複数選択できるようになり,s:source.action_table.myaction.func()
の引数が,選択した候補の辞書ではなく,複数の辞書を含むリストになります.
なお,作成したunite sourceに引数を渡して起動したい場合は, Unite exts:arg1:arg2
のように,コロン区切りで引数を指定する形になります.
この引数は, s:source.gather_candidates
の第一引数 a:args
というリストで受け取ることができます.
この引数を補完する関数は, s:source
のキー complete
に,補完関数の関数参照を指定します.
function! s:source.complete(args, context, arglead, cmdline, cursorpos) abort return filter(['foo', 'bar', 'piyo'], 'v:val =~? ^"' . a:arglead . '"') endfunction
のような形になり, <Tab>
によって選択できる候補のリストを返却すればよいだけです.
ユーザー定義コマンドの補完関数と似たようなもので,引数が2つほど増えているだけですね.
ちなみに,アクションはunite kindとして分離可能であり,丁寧なunite拡張を書くのであれば,候補の取得はsourceで,アクションの担当はkindにした方がよいでしょう(前述の通り,vim-milqiはKindを利用します). kindを指定しなかった場合,untie.vimではkindにcommonが指定されたものとします.
autoload/ctrlp/exts.vim
: ctrlp.vimの拡張
if get(g:, 'loaded_ctrlp_exts', 0) finish endif let g:loaded_ctrlp_exts = 1 let s:save_cpo = &cpo set cpo&vim let s:ctrlp_builtins = ctrlp#getvar('g:ctrlp_builtins') function! s:get_sid_prefix() abort return matchstr(expand('<sfile>'), '^function \zs<SNR>\d\+_\zeget_sid_prefix$') endfunction let s:sid_prefix = s:get_sid_prefix() delfunction s:get_sid_prefix let g:ctrlp_ext_vars = add(get(g:, 'ctrlp_ext_vars', []), { \ 'init': s:sid_prefix . 'init()', \ 'accept': s:sid_prefix . 'accept', \ 'lname': 'sample extension', \ 'sname': 'sample', \ 'type': 'line', \ 'nolim': 1 \}) let s:id = s:ctrlp_builtins + len(g:ctrlp_ext_vars) unlet s:ctrlp_builtins s:sid_prefix function! ctrlp#exts#id() abort return s:id endfunction function! s:init() abort let candidates = ['apple', 'banana', 'cake'] return candidates endfunction function! s:accept(mode, str) abort call ctrlp#exit() echomsg a:str endfunction let &cpo = s:save_cpo unlet s:save_cpo
ctrlp.vimの拡張の作り方に関しては,以下の記事に詳しく書いてあるので,そちらを見ていただいた方がよいでしょう.
ざっと説明すると, s:init()
は候補の取得を担当する関数で, s:accept()
がアクションを担当する関数になります.
ctrlp#exts#id()
は,この拡張のIDを返却する関数で,ctrlp.vimの本体の関数 ctrlp#init()
に渡すことで,ctrlpが起動します.
他の候補選択型インターフェースと比較して,作成した拡張の起動に辞書をそのまま渡せる形にしてほしかったと感じます.
autoload/fzf/exts.vim
: fzfの拡張
let s:save_cpo = &cpo set cpo&vim function! fzf#sampleoption() abort return { \ 'down': 20, \ 'sink': function('s:sink'), \ 'source': s:gather_candidates() \} endfunction function! s:gather_candidates() abort return ['apple', 'banana', 'cake'] endfunction function! s:sink(candidate) abort echo len(readfile(a:candidate)) endfunction let &cpo = s:save_cpo unlet s:save_cpo
fzfはファイルを分離する必要は無いのですが,他のプラグインに習って分離してみました. 拡張の作り方については,以前書いた
を参考にするとよいでしょう.
autoload/alti/exts.vim
: alti.vimの拡張
let s:save_cpo = &cpo set cpo&vim function! s:get_sid_prefix() abort return matchstr(expand('<sfile>'), '^function \zs<SNR>\d\+_\zeget_sid_prefix$') endfunction let s:sid_prefix = s:get_sid_prefix() delfunction s:get_sid_prefix let s:define = { \ 'name': 'exts', \ 'cmpl': s:sid_prefix . 'cmpl', \ 'prompt': s:sid_prefix . 'prompt', \ 'submitted': s:sid_prefix . 'submitted' \} unlet s:sid_prefix function! alti#exts#define() abort return s:define endfunction function! s:cmpl(context) abort return a:context.filtered(self.candidates) endfunction function! s:prompt(context) abort return 'exts> ' endfunction function! s:submitted(context, line) abort if len(a:context.inputs) == 0 echomsg a:context.selection else for input in a:context.inputs echomsg input endfor endif endfunction let &cpo = s:save_cpo unlet s:save_cpo
alti.vimはctrlp.vimに不満があって作成されたプラグインだけあって,拡張の作成はctrlp.vimと似た雰囲気になります. しかし,ctrlp.vimよりパワフルな拡張を作ることが可能になっています. alti.vimは入力することに重きを置いたプラグインという雰囲気です.
上記コードにおいて,候補の取得とフィルタリングは s:cmpl()
が,アクションは s:submitted()
が担当しています.
他の候補絞り込み型プラグインと異なり,s:cmpl()
で入力に対する候補の絞り込みを実装する必要があります.
alti.vim本体には4つのフィルタリング関数が(引数 context
の辞書関数として)用意されており,それぞれ
- 前方一致:
a:context.filtered()
- 後方一致:
a:context.backward_filtered()
- 部分一致:
a:context.partial_filtered()
- 曖昧一致:
a:context.fuzzy_filtered()
となっています. もちろん,自前でフィルタリングを実装してもよいですが,基本的には上記4つのいずれかを用いることで解決できるでしょう.
s:submitted()
において,カーソル下にあった候補は, a:context.selection
に格納され,入力された候補は a:context.inputs
にリストとして格納されています.
alti.vimでは,スペース区切りで候補の入力が認識されるようになっていて,複数の候補を選択する場合は,スペース区切りで入力することになります.
<Tab>
を入力することで,候補窓のカーソル下の候補が入力欄に挿入されるので,ユーザとしては,
- 絞り込みクエリを入力し,候補を絞り込む
- 候補窓でカーソル移動をして,候補を
<Tab>
で完全に入力欄に入力する <CR>
で選択を終え,アクションを実行する
という流れになると思います.
alti.vim的には,ユーザが <CR>
でカーソル下の単語を選択してアクションを実行するのではなく, <CR>
によって,入力された候補を送信することが想定されていると思われます.
したがって, a:context.selection
は基本的にアクション側で見る必要はないでしょう(ただし,受理できる入力が無かった場合は,カーソル下の候補に対してアクションを行うというのもアリかもしれません).
なお,入力された候補は,候補窓からは除外されるようになっているので,重複選択の恐れはありません.
手動で重複した入力を与えたとしても,候補から除外されます.
入力欄に入力されたもののうち,元々の候補群になかったものは除外されて a:context.inputs
に格納されます.
つまり,ユーザがでたらめな入力をしたとしても,それらは全て除外されるということです.
なお,上記コード中の辞書 s:define
の中で,値が関数名の文字列となっているものは,関数参照を取ることも可能です.
関数名を渡すより,関数参照を渡した方がコードとしてはシンプルになるのではないかと思います.
autoload/milqi/exts.vim
: vim-milqiの拡張
let s:save_cpo = &cpo set cpo&vim let s:define = {'name': 'exts'} function! milqi#exts#define() abort return s:define endfunction function! s:define.init(context) abort " let context.i = 0 return ['apple', 'banana', 'cake'] endfunction function! s:define.accept(context, candidate) abort call milqi#exit() echomsg a:candidate endfunction " function! s:define.lazy_init(context, query) abort " let a:context.i += 1 " if query ==# '' " return { " \ 'reset': 0, " \ 'candidates': map(['lazy_apple', 'lazy_banana', 'lazy_cake'], " \ 'v:val . " - " . a:context.i') " \} " else " return { " \ 'reset': 1, " \ 'candidates': ['apple_query', 'banana_query', 'cake_query'] " \} " endif " endfunction " function! s:define.async_init(context) abort " let a:context.i += 1 " return { " \ 'done': a:context.i < 6 ? 0 : 1, " \ 'candidates': map(['async_apple', 'async_banana', 'async_cake'], " \ 'v:val . " - " . a:context.i') " \} " endfunction let &cpo = s:save_cpo unlet s:save_cpo
辞書関数ばかりとなっていますが,コードの雰囲気は,ctrlp.vimと似ていますね.
今回の例では活用していませんが(例が悪いのは申し訳ありません),vim-milqiの一番の特徴は, query-first と candidate-first という2つのインタラクションモードです.
上記コード中では s:define.lazy_init
, s:define.async_init
が,それらのインタラクションモードでのアクションに関係しています
この2つのインタラクションモードは,vim-milqiのセールスポイントを語る上では欠かせない要素です.
以下,2015年3月29日のLingrの過去ログからの引用です.
query-firstは入力に応じて動的に内容が変化する系,candidate-firstは単なる非同期ctrlp系ですね candidate-firstだと,例えば時間のかかる処理で候補の取得に時間のかかるもの向けで考えていて query-firstだと,例えば過去の選択状態に応じて最初に一部の候補をキャッシュから出し,入力があれば入力に対応した候補を動的に生成して出すということができます candidate-firstはuniteやctrlpを考えていただければイメージは掴みやすいと思います query-firstは,こっちが私のやりたいメインですが,例えば初期表示でa, bを表示,hogeと入力したときにc, d, eを表示,hogeを消してfugaと入力したときにf, g, h, iを表示,ということができます
candidate-first
candidate-first は,unite.vimにもある「非同期の候補の取得ができるもの」といえるでしょう.
candidate-firstモードとして利用する場合, milqi#candidate_first()
に拡張の定義辞書を渡します.
この辞書のキー async_init
に関数参照が指定されていれば, updatetime
ハックにより,定期的にその関数がコールされます.
async_init
に指定された関数は,done
と candidates
をキーに持つ辞書を返却する必要があります.
キーの名前の通り, done
は非同期処理が終了したかどうかを, candidates
は非同期で取得した候補を表します.
candidate
に指定したリストは既にある候補群に追加されるので,拡張を作成する側が候補リストの要素に追加するといった処理は不要です.
done
に 1
を指定したとき,非同期処理は完了したものとして,関数を定期的なコールが終了します.
query-first
query-first は,入力に応じて,候補を動的に生成する,といったものらしいです.
query-firstモードとして利用する場合, milqi#candidate_first()
に拡張の定義辞書を渡します.
この辞書のキー lazy_init
に関数参照が指定されていれば, updatetime
ハックにより,定期的にその関数がコールされます.
ここまでは,candidate-firstモードと変わらないですが, lazy_init
に指定した関数は第二引数に入力クエリを受け取ることができます.
そして,返り値は reset
と candidates
をキーに持つ辞書を返却しなくてはなりません.
candidates
に指定したリストが候補群に追加されていくのは,candidate-firstモードの async_init
と同様ですが, reset
に 1
を指定することで,候補群を(ただの init
で返却した候補リストも含めて)全て除去することができます.
入力クエリと reset
を活用して,候補を動的に変更するのが,query-firstモードの使い方といえるでしょう.
単純な例の場合
今回のような単純な例(拡張辞書に lazy_init
と async_init
のどちらも持たない例)では,どちらのモードでも(すなわち, milqi#query_first()
と milqi#candidate_first
のどちらに拡張の定義辞書を渡しても),動作に違いは無いでしょう.
なお,上記コード中のコメントアウト部分を解除し, plugin/exts.vim
のvim-milqiに関する部分を適切に変更することで,candidate-first , query-firstのそれぞれを体験することができるようにしています.
candidate-firstの場合は,関数が5回コールされるまで,候補が増えるようにしています.
query-first の場合,何か入力すると,候補が apple_query
, banana_query
, cake_query
の3つだけになるようにしています.
感想
この記事では5つの候補絞り込み型インターフェースを提供するプラグインについて,簡単に説明しました. どれもざっと見た限りでは「候補絞り込み型」という風にまとめられますが,それぞれが実現したいことや,目指しているものが少しずつ違っていて面白いですね.
unite.vimは初めから拡張を見越して作られた分,第三者にとってはかなり自由かつパワフルに拡張を作ることができるようになっています.
また,作成したsourceの呼び出しは, :Unite
のサブコマンド,すなわち第一引数にsource名を与える形になっているので,source作成者が plugin/
下のファイルにコマンドを定義しなくてよいのも魅力です.
:Unite
のオプション引数である -auto-preview
などは,uniteにしかできないことでしょう.
ctrlp.vimは元々がファイラであり,拡張が後付けのようなものであるため,作ることのできる拡張にやや不自由があるのは否めません. 例えば,複数選択がファイルを開くという操作に限定されるので,第三者にとっては利用しにくいものでしょう. しかし,コンパクトな実装である分,unite.vimより初回の起動時間が短い点は評価できるでしょう.
fzfは外部コマンドとして利用するので,一時的にVimから出る形になります. その点が他の絞り込み検索型のプラグインと比べると異質なところではあります. しかし,tmux上であれば,画面分割を利用し,候補を表示することができたり,neovimであればneovimのターミナルエミュレーター上で起動するようにしていたり,とても面白い機能が実装されています.
alti.vimはctrlp.vimにインスパイアされて開発されたプラグインだけあって,細かい部分での配慮が行きとどいていますね. 本体から前方一致,後方一致,部分一致,ファジーマッチという4つの絞り込み関数が提供されているのも,なかなかよいと感じました. ctrlp.vimはファジーマッチしかできないようになっており,僕としては多数の候補のフィルタリングに苦労することがありました.
vim-milqiは,unite souceから候補を取得できるようにしているのは画期的な機能だと思いました. また,非同期処理を主眼に置いており,他の絞り込み検索プラグインには無い特徴を感じ取ることができました.
おわりに
絞り込み検索型プラグインの拡張を作ることは難しくありません. この記事で紹介したような,たった十数行程度のコード(ほとんどが定型句のようなもので,本質的な部分は数行程度)を書くだけで,自作のプラグインから絞り込み検索型プラグインのUIを利用することができます. もし,これを読んでいるあなたがプラグイン作成の初心者であり,unite sourceやctrlp.vimの拡張を書いたことが無いのであれば,この機会に挑戦してみるのはいかがでしょうか?
なお,unite.vimは日本での知名度がとても高く,ググれば多数の情報が出てくると思います. unite.vimは高機能なため,この記事では紹介しきれていないので,unite.vimの本当の力を知るためにもググることをオススメします.
参考
- Shougo/unite.vim
- ctrlpvim/ctrlp.vim
- junegunn/fzf
- LeafCage/alti.vim
- kamichidu/vim-milqi
- ctrlp.vimのエクステンションの作り方 - koturnの日記
- Vimからfzfを利用する - koturnの日記
- ctrlp.vimの使い方まとめ - Qiita
- ctrlp.vimを使う - Qiita
- ユーザに入力をさせるinput()のインターフェイスが不満すぎて仕方がないならctrlp.vim風の入力インターフェイスalti.vimを使おう - cafegale
- 公開後から随分経ってわかったalti.vimの問題点 - LeafCage備忘録
- alti.vim のアイデンティティを作者である私が失いかけたので整理しておく
- vim-jp - Lingr March 29, 2015