読者です 読者をやめる 読者になる 読者になる

ユーザに入力をさせるinput()のインターフェイスが不満すぎて仕方がないならctrlp.vim風の入力インターフェイスalti.vimを使おう

Vim Advent Calendar 2012 333日目の記事です。

alti.vimctrlp.vim風の操作感を持つ入力インターフェイスです。

もしもユーザに多少複雑めの入力を要求したい場合は、Vimの組み込み関数input()でそれを実現するのが苦しいことがあります。
なぜならinput()には

という弱点があります。他にも操作性の悪さなどが弱点に挙げられます。

input()やコマンドライン補完で複雑な入力をユーザに負担なくさせるのは難しいと気付き、unite.vimctrlp.viminput()みたいなことをするのも恐らく不可能だ*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のインターフェイスです。
f:id:leafcage:20131029191600p:plain
選択肢にfish, fast, dish, first, fireが表示されています。
f:id:leafcage:20131029191609p:plain
fを入力すると候補が絞り込まれてdishが除外されて候補が4つになります。
f:id:leafcage:20131029191618p:plain
さらにiを入力するとfastが除外されて候補が3つになります。

f:id:leafcage:20131029191629p:plain
<C-j><C-k>firstに合わせてから
f:id:leafcage:20131029191638p:plain
<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()の第二引数に文字列を渡します。この文字列は起動の初めにだけ表示されます。
f:id:leafcage:20131029191724p:plain
↑間違った入力("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)

こうすると今何番目の引数を入力しているかによって補完候補やプロンプト文字列を変更させることが出来ます。
f:id:leafcage:20131029194843p:plain
一番目の入力が終わるとプロンプト文字列がinput "second"へと変更され、候補もsection, moment, second, first, thirdに変更されました。
f:id:leafcage:20131029194929p:plain
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

f:id:leafcage:20131029191920p:plain
f:id:leafcage:20131029191929p:plain
この通り、補完を挿入しても後ろの注釈は挿入されません。
これは標準の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.vimdict属性付きのユーザ定義関数内でいつでも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を利用したプラグインを公開するのは大分先になりますので、それまでは付属ヘルプドキュメントで詳細を補完してください。LingrTwitterでの質問も受け付けています。

*1:もしかするとunite.vimでは私がやり方を知らないだけで不可能ではないのかも知れませんが、向いてはいないでしょう。

*2:標準キーマッピングは予告なく変更する可能性があります。

*3:ここで解説していない要素についてはヘルプドキュメントをご覧ください。