ユーザに入力をさせるinput()のインターフェイスが不満すぎて仕方がないならctrlp.vim風の入力インターフェイスalti.vimを使おう
Vim Advent Calendar 2012 333日目の記事です。
alti.vimはctrlp.vim風の操作感を持つ入力インターフェイスです。
もしもユーザに多少複雑めの入力を要求したい場合は、Vimの組み込み関数input()
でそれを実現するのが苦しいことがあります。
なぜならinput()
には
- 補完が不完全(2つめの引数を補完できない)
<Esc>
されたのか、何も入力無しで<CR>
されたのか判別不可
という弱点があります。他にも操作性の悪さなどが弱点に挙げられます。
input()
やコマンドライン補完で複雑な入力をユーザに負担なくさせるのは難しいと気付き、unite.vimやctrlp.vimでinput()
みたいなことをするのも恐らく不可能だ*1と考えた私は、新しい入力インターフェイスを開発しました。ちょうどctrlp.vimを本格的に使い始めて、そのインターフェイスを気に入ったので、その影響をかなり受けています。補完の候補が常時、窓に表示されて、入力と共に絞り込まれていくような感じで。
基本の仕組みはctrlp.vimのものを踏襲しましたので、設定変数もctrlp.vimに似せています。
ヘルプに詳しく記しましたのでご参照ください。
操作法を簡単に説明しますと、<C-j>
<C-k>
で補完候補を選択、<Tab>
で補完候補を挿入、<C-f>
<C-b>
でページ送り/戻し(補完候補が多すぎるときのみ)、<C-h>
<C-l>
で左右移動、他はコマンドラインの標準の操作とだいたい同じです。<CR>
で入力したコマンドを実行します。*2
alti.vimは拡張あって初めて動作するので、これから拡張の作り方を解説していきます。
alti.vimの基本
alti#init()
に、定義を書いた辞書を渡せばalti.vimが起動します。
辞書の要素は、default_text, static_text, enter, comp, prompt, insertstr, submitted, canceled, append_compsep, type_mulibyte がありますが、全てを定義しなければいけないわけではありません。*3
補完に使われる関数名を指定するcompと、プロンプトメッセージを返す関数名を指定するprompt、ユーザが入力を提出したときに呼ばれる関数名を指定するsubmittedで十分でしょう。省略した要素には標準の値が使われます。
let s:first_define = { \ 'comp': 'FirstComp', \ 'prompt': 'FirstPrompt', \ 'submitted': 'FirstSubmitted' \ } function! FirstComp(arglead, cmdline, cursorpos) return filter(['fish', 'fast', 'dish', 'first', 'fire'], 'v:val=~"^".a:arglead') endfunction function! FirstPrompt(arglead, cmdline, cursorpos) return "input \"first\" \n> " endfunction function! FirstSubmitted(input, laststate) if a:input=~'^\s*$' echo 'no input' elseif a:input=~'first' echo 'ok' else echo '"'. a:input. '" is not "first"' end endfunction call alti#init(s:first_define)
"first"を入力してくださいという拡張例です。特に意味はありません。
このように書いたファイルを:source
すると、alti.vimが起動します。
最下段のウィンドウとコマンドラインがalti.vimのインターフェイスです。
選択肢にfish, fast, dish, first, fireが表示されています。
f
を入力すると候補が絞り込まれてdishが除外されて候補が4つになります。
さらにi
を入力するとfastが除外されて候補が3つになります。
<C-j>
<C-k>
でfirstに合わせてから
<Tab>
(<C-y>
や<C-n>
でも可)を押すとfirstが補完されます。
空白が挟まれたので、1つ目の入力が完了したと見なされ、補完候補は初期状態に戻ります。
このまま<CR>
で確定すると'ok'と表示されます。
もしも何も入力しないまま<CR>
すると'no input'が表示され、間違った入力で<CR>
すると'"xxx(入力)" is not "first"'と表示されます。
もし、何も入力がされなかったり、間違った入力がされたときにメッセージを表示した後、再びaltiを呼ぶときにはsubmitted関数を次のようにします。
function! FirstSubmitted(input, laststate) if a:input=~'^\s*$' call alti#init(s:first_define, 'no input') return elseif a:input=~'first' echo 'ok' else call alti#init(s:first_define, '"'. a:input. '" is not "first"') return end endfunction
中で再びalti#init()
を呼んでいます。その際、第二引数に開始直後に表示させたいメッセージを渡しています。
alti.vimは通常のechoの表示を潰してしまうので、altiに入ったときに何か一時的なメッセージを表示したければalti#init()
の第二引数に文字列を渡します。この文字列は起動の初めにだけ表示されます。
↑間違った入力("dish")を渡したときの表示例("dish" is not "first")。
注意:submittedの中などで再びalti#init()
を呼んだ場合は、その後には何も処理をしないでreturnしてください。そうしないと呼んだ先のaltiと呼び出し元のaltiで二重に処理を行うことになりかねません。
2つ目の引数では違う補完候補を出す
Vimのコマンドラインの補完は今のコマンドラインが何番目の引数を入力しているのかによって候補を変更することがとても難しかったです。
alti.vimではalti#get_arginfo()
という関数を使うことで、今現在の補完の文脈を得ることが出来、柔軟な補完が比較的簡単に実現できます。
let s:second_define = { \ 'comp': 'SecondComp', \ 'prompt': 'SecondPrompt', \ 'submitted': 'SecondSubmitted' \ } function! SecondComp(arglead, cmdline, cursorpos) let arginfo = alti#get_arginfo() if arginfo.ordinal == 1 return filter(['fish', 'fast', 'dish', 'first', 'fire'], \ 'v:val=~"^".a:arglead') elseif arginfo.ordinal == 2 return filter(['section', 'moment', 'second', 'first', 'third'], \ 'v:val=~"^".a:arglead') else return [] end endfunction function! SecondPrompt(arglead, cmdline, cursorpos) let arginfo = alti#get_arginfo() if arginfo.ordinal == 1 return "input \"first\" \n> " elseif arginfo.ordinal == 2 return "input \"second\" \n> " else return "\n> " end endfunction function! SecondSubmitted(input, laststate) let inputs = split(a:input) let first = get(inputs, 0, '') let second = get(inputs, 1, '') if first=='first' if second=='second' echo 'ok' else call alti#init(s:second_define, \ 'first is correct, but second is wrong: '. a:input) end return end if inputs==[] call alti#init(s:second_define, 'no input') else call alti#init(s:second_define, 'first input is wrong: '. a:input) end endfunction call alti#init(s:second_define)
こうすると今何番目の引数を入力しているかによって補完候補やプロンプト文字列を変更させることが出来ます。
一番目の入力が終わるとプロンプト文字列がinput "second"へと変更され、候補もsection, moment, second, first, thirdに変更されました。
alti#get_arginfo()
で得られる辞書arginfo
の要素arginfo.ordinal
には、今現在何番目の引数を入力しているのかが入っています。
これによって、1番目の引数と、2番目の引数の入力で違う補完候補を出させることが出来ます。
プロンプト文字列も文脈によって変更させられます。
arginfo
には、他にも以下の要素が入れられています(執筆時点)。
precursor | カーソルより前の文字列 |
---|---|
postcursor | カーソルを含むカーソルより後の文字列 |
inputline | 入力文字列全体 |
cursoridx | カーソルの位置(バイト単位のインデックス) |
arglead | 補完対象の文字列 |
ordinal | 現在、先頭から何番目の引数の補完を行っているのか。初めは1から始まる。エスケープされていない空白を区切りと見なす。 |
args | 入力された文字列を非エスケープ空白で分割したリスト。 |
また、submitted関数に渡される2番目の引数laststate
はこれらの要素に加え、lastselected
という、最後に選択されていた補完候補を得ることが出来ます。あまり使う機会はないでしょうが、ユーザがうっかり最後の補完を完成させずに<CR>
を押してしまったときの温情処置を組み込むのに使えるかも知れません。
補完に注釈を付ける
comp関数に返す候補は、標準で、候補文字列の後にタブ文字を置いて注釈を付けることが出来ます。
function! FirstComp(arglead, cmdline, cursorpos) return filter(['fish 魚', 'fast 速い', 'dish 皿', 'first 第一の', 'fire 火'], 'v:val=~"^".a:arglead') endfunction
この通り、補完を挿入しても後ろの注釈は挿入されません。
これは標準のinsertstr関数がタブ文字より後を削除しているからです。
insertstr関数は選択された候補を実際に挿入する文字へと変換する役割を持っています。
標準のinsertstr関数は以下のようになっています。
function! alti#insertstr_posttab_annotation(arglead, selected_candidate) call alti#on_insertstr_rm_arglead() return substitute(a:selected_candidate, '\t.*$', '', '') endfunction
a:selected_candidate
からタブ文字以降を削除しています。
insertstr関数はdefine
で変更することが出来ます。特殊な候補の挿入がうまくいかないときにinsertstrを触ることでうまくいくかも知れません。
また、ここではalti#on_insertstr_rm_arglead()
という関数が使われています。これによりselected_candidate
からarglead
を除去する必要をなくしています。
元のバッファの情報を利用する&dict属性付きの関数でself変数を使う
altiを起動するとAltIバッファに移動するので、alti起動前のバッファの情報が取得できなくなります。
'filetype'などの、バッファローカルなオプションも同様です。
ウィンドウも移動しますので元のウィンドウの情報もcomp関数内などでは取得できません。
もしそれらの情報を利用したければ、enter関数を指定します。
enter関数はAltIバッファに移る前に呼ばれる関数です。この中でなら、呼び出し前のバッファの情報を取得できます。
また、alti.vimの機能として、functionの定義時にdict属性をつけると、空の辞書変数self
が利用できるようになります。
self
はalti.vimが使うdict属性付きのユーザ定義関数全てで共有されます。
現在編集中のバッファから'# 'で始まる行をタイトルと見なし、拡張子も取得してそれらをリネーム候補として表示する拡張が以下になります。
function! ThirdDefine() return { \ 'enter': 'ThirdEnter', \ 'comp': 'ThirdComp', \ 'prompt': 'ThirdPrompt', \ 'submitted': 'ThirdSubmitted' \ } endfunction function! ThirdEnter() dict let self.titles = filter(getline(1, '$'), 'v:val=~"^#\\s"') let self.filetype = &filetype endfunction function! ThirdComp(arglead, cmdline, cursorpos) dict return filter(map(self.titles, 'v:val. ".". self.filetype'), 'v:val=~"^".a:arglead') endfunction function! ThirdPrompt(arglead, cmdline, cursorpos) return "input filename \n> " endfunction function! ThirdSubmitted(input, laststate) exe 'saveas' a:input endfunction
これをこのソースの外から以下のコマンドで呼び出します
:call alti#init(ThirdDefine())
dict属性を持つ関数内のself
に値を渡した後、別の関数のself
からそれを取り出して利用しているのが分かるでしょうか(この場合はpreenter関数で値を定義しからcomp関数で値を取り出しています。)。
このself
は初期値は{}
ですが、alti#init()
の第三引数に任意の辞書を渡すことで変更することが出来ます。
:call alti#init(ThirdDefine(), '', {'thanks': 'kien'})
という辞書を渡すことで、alti.vimのdict属性付きのユーザ定義関数内でいつでもself.thanks
という値を利用することが出来ます。
alti.vim拡張の設定が大きくなったら別のファイルに分ける
この記事内ではわかりやすさのためにグローバル関数を使って、1つのファイル内で定義と呼び出しを完結させましたが、altiの定義は別ファイルに置くことを推奨しています。autoload/alti/{拡張の名前}.vimというファイルです。
一番目の例の場合、autoload/alti/first.vimに置いて、次のような定義を返す関数を定義します。
function! alti#first#define() return { \ 'comp': 'alti#first#define', \ 'prompt': 'alti#first#prompt', \ 'submitted': 'alti#first#submitted' \ } endfunction
そして各関数をalti#first#xxxという名前にリネームしておいて、別の場所で次のように呼び出します。
nnoremap <silent><Plug>(first_alti) :<C-u>call alti#init(alti#first#define())<CR>
以上、alti.vimの紹介でした。
恐らく私がalti.vimを利用したプラグインを公開するのは大分先になりますので、それまでは付属ヘルプドキュメントで詳細を補完してください。LingrやTwitterでの質問も受け付けています。