水曜日の夜に生まれたminimap-vim、このプラグインを私がどう設計し実装したのか、その思考と行動の推移を再現してみましょう。なおこの記事はVim Advent Calendar 20122日目の参加記事となります。
前置き
Sublime Textのminimap確かにかっこいいなぁ。マーカー程度のことならsignでできるけどなぁ
Sublime Textのminimap確かにかっこいいなぁ。マーカー程度のことならsignでできるけどなぁ
— tyruさん (@tyru) 2012年11月28日
きっかけは tyru さんのこのつぶやきが目に止まったことでした。これを見た私は、ふとあることを思いつきます。
@tyru ふと思った。gvimもう一つたちあげて、ものっそい小さなフォントで表示してあげれば良いのでは?
— MURAOKA Taroさん (@kaoriya) 2012年11月28日
この思い付きが正しいのか、ちょっと試しにやって見ることにしました。単にgvimを2つたちあげて並べてみるだけの簡単な作業です。
Vimを使ったminimapもどき、イメージだけ作ってみた。 pic.twitter.com/H1aYr3di
— MURAOKA Taro (@kaoriya) 2012年11月28日
この時のフォントサイズはわずか3ポイント。設定で言うと
set guifont=MS_Gothic:h3:cSHIFTJIS
となります。なんか良さげに見えますね。ではちょっと作ってみましょう。
設計とコーディング
まず最初にコマンドでminimapを表示すること決めました。カーソルを動かすたびにとかは autocmd でできますが、いきなりそれをやってしまうとデバッグが面倒になりますからね。具体的には plugin/minimap_loader.vim にてこんなことをするわけです。
command! MinimapSync call minimap#sync()
コマンドラインにて :MinimapSync を実行するたびに、minimap に状態が反映されるという寸法です。
minimap の表示を、2つの gvim インスタンス間の表示情報の同期と捉えこのような名前にしました。そうしておもむろに minimap#sync() の中身を autoload/minimap.vim へ書いてしまいます。
let s:minimap_id = 'minimap'
function! minimap#sync()
let id = s:minimap_id
if minimap#_is_open(id) == 0
call minimap#_open(id)
call minimap#_wait(id)
endif
call minimap#_send(id)
endfunction
ハイ、これでもうできたも同然です。実際に最初にこのように書きました。ココで考えていることはこうです。
- minimap 用のインスタンスがなかったら起動して(その起動を待って)
- 表示データを送る
あ、もうできてるじゃん。簡単簡単♪
では最初の _is_open(), _open(), _wait() の3つの関数を書いてみましょう。インスタンス間の通信には Vim の +clientserver 機能を使うつもり。とりあえず通信相手を決めるのに名前(ID)は必須なので引数で渡すようにします。
function! minimap#_is_open(id)
let servers = split(serverlist(), '\n', 0)
return len(filter(servers, 'v:val ==? a:id')) > 0 ? 1 : 0
endfunction
function! minimap#_open(id)
let args = [
\ 'gvim',
\ '--servername', a:id,
\ ]
silent execute '!start '.join(args, ' ')
endfunction
function! minimap#_wait(id)
" FIXME: improve wait logic
while minimap#_is_open(a:id) != 0
sleep 100m
endwhile
sleep 500m
endfunction
_is_open() は serverlist() を全インスタンスのIDが返ってくるので余裕です。リストの中に指定要素があるかどうかの判定に filter() を使っていますが、Java なんかであれば indexOf などを使うところです。
_open() についてはh_eastさんに助けてもらってます。gvimからgvimを起動するのに適した方法って、あることは覚えていたんですがすっかり忘れていました。当初は system() を使っていたんですが、これは ‘shell’ に tcsh を指定しないと、意図通りに動いてくれないのです。
_wait() は一番不満の多いところ。100msec毎にポーリングして起動を待った上に、さらに様々な初期化が終わるのを決め打ちで500msec待つというアドホックなコードです。これは将来 minimap インスタンス側から remote なんたらでコールバックさせるのが良いでしょう。ですが、とりあえず単に動作を見てみたい今の時点では良しとします。
最後に _send() を書いてしまえばメイン側のコードは終わりです。見てみましょう。
function! minimap#_send(id)
let data = {
\ 'path': substitute(expand('%:p'), '\\', '/', 'g'),
\ 'line': line('.'),
\ 'col': col('.'),
\ 'start': line('w0'),
\ 'end': line('w$'),
\ }
call remote_expr(a:id, 'minimap#_on_recv("' . string(data) . '")')
endfunction
解説する場所なんて無いほどに簡単ですね。現在開いているファイルと、カーソルの位置、それに表示している最初と最後の行数を取得し、JSON形式の文字列にして minimap 側インスタンスに remote_expr() で送信しています。リモート側で呼び出す関数名は minimap#_on_recv() としました。
function! minimap#_on_recv(data)
let data = eval(a:data)
let path = data['path']
if len(path) == 0
return
endif
let file = substitute(expand('%:p'), '\\', '/', 'g')
if file !=# path
execute 'view! ' . path
endif
if file ==# path
let col = data['col']
let start = data['start']
let curr = data['line']
let end = data['end']
" TODO: ensure to show view range.
call cursor(start, col)
call cursor(end, col)
" mark view range.
let p1 = printf('\%%>%dl\%%<%dl', start - 1, curr)
let p2 = printf('\%%>%dl\%%<%dl', curr, end + 1)
silent execute printf('match Search /\(%s\|%s\).*/', p1, p2)
" move cursor
call cursor(curr, col)
redraw
endif
endfunction
_on_recv() が読む上では一番の難所かもしれません。ただ、実際にやってることは非常に簡単なので、書くのも簡単です。
- 表示データをJSONとして解釈し(JavaScriptのJSON.parse相当)
- ファイルが開かれてなかったら開き
- 表示範囲を :match コマンドで明示し
- カーソル位置を同じ場所に移動させる
というだけです。match 用の正規表現がちょっと難しいですが、ヘルプ (:help /\%l) を見ればわかるでしょう。2つに分けているのはあとでやる ‘cursorline’ の設定との相性のため、カーソルのある行 (curr) をマッチ対象から除いています。
さあ、これで :MinimapSync はその主機能が実装できました。あとは minimap 側インスタンスが起動した時に呼ばれる関数 minimap#_on_open() とその呼び出し部分を作ってしまいましょう。まずは呼び出し部分で plugin/minimap_loader.vim にこんなのを書き足します。 minimap側インスタンスならば起動処理がすべて終わった後に minimap#_on_open() を呼び出すというだけのコードです。
if v:servername =~? 'minimap'
augroup minimap
autocmd!
autocmd VimEnter * call minimap#_on_open()
augroup END
endif
で、本体の minimap#_on_open() では表示に関わる設定をするだけです。
function! minimap#_on_open()
set guioptions= laststatus=0 cmdheight=1 nowrap
set columns=80 foldcolumn=0
set cursorline
set guifont=MS_Gothic:h3:cSHIFTJIS
hi clear CursorLine
hi link CursorLine Cursor
winpos 0 0
set lines=999
endfunction
超簡単ですね。あ、最後の部分(8-9行目)は画面の左端に縦最大サイズで配置するコードですね。この表示設定は、個人毎に好みがわかれるところでしょうから、将来的にはかなり柔軟にカスタマイズ可能にするのが良いはずです。
あとはそうですね…送信側でこんなことをすれば、カーソルを動かすたびにその情報が minimap 側インスタンスに反映される用になるはずです。
augroup minimap_auto
autocmd!
autocmd CursorMoved * call minimap#sync()
augroup END
なおこれらを書いた順序はここで解説したのまったく同じでした。
とりあえず完成
さぁ、これにて完成しましたので公開しておきましょう。
というわけでgvim用のminimap作ってみた。 github.com/koron/minimap-…
— MURAOKA Taroさん (@kaoriya) 2012年11月28日
公開版にはもう少し手が入っています。特に :MinimapSync では CursorMoved イベントの設定までやるようにしたのと、minimap 側インスタンスへの送信を止めるコマンド :MinimapStop を定義してみました。この2つは排他的に定義されるようにしたので、:Mini(タブキー) と入力すると簡単にオン・オフ切り替えられるようになっています。
公開後、まっつんさんに幾つか修正をしてもらって、それらは上記コードにも反映されています。
興味が湧いた人は是非、公開版のソースコードを読んでみてください。それでわからないことがあれば遠慮なく聞いてください。
しかし…
初めは自分で作るつもりなんてなかったんですが、ちょっとアウトラインだけ書いてみようとか、ここの処理どうやって書けば良いんだっけと、細かく見通しよくしていたら、気がついたらできちゃってました。
…ただ私、minimapがどういうものかよくわからずに作りました。使ったこともありません。
— MURAOKA Taroさん (@kaoriya) 2012年11月28日
で、そうやって作ってみたものの、結局私は使わないんだと思いますw