popup windowsで簡易ファイラーを作る

ども、ゴリラです。
今年もやってまいりましたVimアドベントカレンダーです。

今日はpopup windowを使ってファイル一覧をpopup windowで選択して開くプラグインを作ってみようと思います。
プラグイン作ってみたい方は、ぜひ読んでみてください。意外とプラグインは簡単に作れます。

# はじめに
まずどんな機能を作るかを整理します。本記事は次の機能を実装することをゴールにします。

1. 指定パスのファイル一覧をpopup windowに表示
2. 選択したファイルを開く（分割、タブ、カレントバッファ）

# ディレクトリ構成
プラグインのディレクトリ構成は以下にします。一般的に`autoload`配下にメインの処理関数を定義して、
`plugin`でメイン処理の関数を呼び出すコマンドを定義します。

`autoload`に定義した関数は、コマンドで呼び出されるまでロードされないようになっています。
これは、Vimの起動時間に影響を与えないようにするためです。

```sh
popupfiles.vim/
├── autoload
│   └── popupfiles.vim
└── plugin
    └── popupfiles.vim
```

# ディレクトリの置き場
プラグインマネージャーを使う方が多くいると思います、プラグイン開発を行うとき筆者はよく`packages`機能を使用します。
`packpath`配下に`start`ディレクトリを作成してそこにディレクトリを置くことで、Vim起動時にプラグインを読み込んでくれます。

ちなみに、筆者の場合は次のようになっています。

```
/Users/skanehira/.vim/pack/plugins/start/popupfiles.vim
```

詳しく知りたい方は`:h packages`でヘルプを引いてみてください。

## ファイル一覧をpopup windowに表示
### ファイル一覧を取得
まずはカレントディレクトリ配下のファイルを一覧の配列を返す関数を用意します。
コードは`autoload/popupfiles.vim`に記述します。

```vim
" パスのセパレーターを取得
let s:sep = fnamemodify('.', ':p')[-1:]

function! s:get_files(path) abort
    " `expand()`はパスをフルパスに展開
    " 例えば`~/`は`/home/gorilla`に展開
    let p = expand(a:path)
    let entries = []

    " `readdir`は指定したディレクトリ内の要素を配列で返す
    for f in readdir(p)
        " `.`から始まるディレクトリとファイルをスキップ
        if f[0] is# '.'
            continue
        endif

        let file_path = p . s:sep . f
        if isdirectory(file_path)
            let entries += s:get_files(file_path)
        else
            if file_path[0] is# '.'
                let file_path = file_path[2:]
            endif
            call add(entries, file_path)
        endif
    endfor

    return entries
endfunction
```

### popup windowを作る
次にpopup windowを作る関数を定義します。popup windowを作る関数はいくつかありますが、今回は`popup_menu`を使用します。`popup_menu`を使用することで要素の選択、操作を簡単に実装できます。

こちらも、コードは`autoload/popupfiles.vim`に記述します。

```vim
function! popupfiles#files() abort
    call popup_menu(s:get_files('.'), {})
endfunction
```

ここで1つポイントです。`popupfiles#files()`という関数名は`plugin`で呼び出すために`autoload`のルールに従って命名しています。
`autoload`の命名ルールは`filename#funcname()`になっています。こうすることで、どのファイルのどの関数を呼び出すかVim側が判断できるようになるからです。

### popup windowを呼び出す
メインの処理ができたので、次に`plugin`で`popupfiles#files()`を呼び出すコマンドを次のように定義します。

```vim
" プラグインを再度ロードしないようにガード
if exists('g:loaded_popupfiles')
  finish
endif

let g:loaded_popupfiles = 1

" vi互換の設定を一旦退避
let s:save_cpo = &cpo
set cpo&vim

" コマンドの定義
command! PopupFiles call popupfiles#files()

" 互換設定を戻す
let &cpo = s:save_cpo
unlet s:save_cpo
```

一度Vimを再起動するか、`:source plugin/popupfiles.vim`と`:source autoload/popupfiles.vim`でファイルを読み込むかしてください。

適用後`:PopupFiles`コマンドを実行すると次のようにpopup windowにファイル一覧が表示されるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/666e62b7-0e13-390b-497f-a201c00f0bcc.png)

これでpopup windowにファイル一覧を表示させることができました。
ちなみに、`popup_menu`ではデフォルトで次のキーが利用できます。

| キー               | 説明       |
|--------------------|------------|
| `j`                | 次の要素   |
| `k`                | 前の要素   |
| `enter`/ `space`    | 要素を選択 |
| `x` / `esc` / `ctrl-c` | キャンセル |

では、次に選択したファイルを開くキーバインドを追加していきましょう。

## 選択したファイルを開く
`popup_menu`では第1引数に要素、第2引数にオプションを渡すことができます。
もう一度コードを見てみましょう。第2引数は`{}`は空のオブジェクトになっています。
このオブジェクトにオプションを定義していきます。

```vim
function! popupfiles#files(path) abort
    call popup_menu(s:get_files(a:path), {})
endfunction
```

popup windowには`filter`というオプションがあります。
このオプションではキー入力を受け付けて何かしらの処理を行う関数を指定することができます。

このフィルター関数、デフォルトでは`winid`と`key`の2つの引数を受け取ります。
フィルター関数の定義と呼び出しは次のようになります。

```vim
function! popupfiles#files() abort
    call popup_menu(s:get_files('.'), {
                \ 'filter': function('s:popup_files_filter')
                \ })
endfunction

function! s:popup_files_filter(winid, key) abort
    " 入力キーに応じた処理

    " それ以外は通常のpopup_filter_menuに渡す
    return popup_filter_menu(a:winid, a:key)
endfunction
```

ここで1つポイントですが、`function()`で関数への参照を取得して、`filter`オプションに渡す必要があります。

また、`popup_filter_menu`というVimの組み込み関数がありますが、これは`filter`が何も指定されないときにデフォルトで使用されるフィルター関数になっています。この関数を使用することで`j`や`k`といったキーバインドの操作が出来るようになります。

しかし、このまま実装しようとすると、
フィルター関数に選択したファイルが渡ってこないため、ファイルを特定できないという問題があります。
そこで、Vim scriptのPartialという仕組みを使用して必要な情報をフィルター関数の引数に追加します。
`function()`の第2引数に配列を渡すことで、その中身が関数の引数として先頭に順に追加されます。

```vim
function! popupfiles#files() abort
    let files = s:get_files('.')
    let ctx = {
                \ 'idx': 0,
                \ 'files': files,
                \ }

    call popup_menu(files, {
                \ 'filter': function('s:popup_files_filter', [ctx])
                \ })
endfunction

function! s:popup_files_filter(ctx, winid, key) abort
    " 入力キーに応じた処理

    " それ以外は通常のpopup_filter_menuに渡す
    return popup_filter_menu(a:winid, a:key)
endfunction
```

`idx`は`files`から選択したファイルを取り出すために必要な配列番地の情報です。

これで必要な情報をフィルター関数内で扱うことができるので、あとはキー入力に応じてファイルを開く処理を実装すれば良いです。

今回は、次のキーバインドを実装することにします。

| キー   | 説明                   |
|--------|------------------------|
| enter  | カレントバッファに開く |
| ctrl-v | 水平分割で開く         |
| ctrl-x | 垂直分割で開く         |
| ctrl-t | タブページで開く       |

実装したコードが次になります。

```vim
function! s:popup_files_filter(ctx, winid, key) abort
    " 入力キーに応じた処理
    if a:key is# 'j'
        if a:ctx.idx < len(a:ctx.files) -1
            let a:ctx.idx = a:ctx.idx + 1
        endif
    elseif a:key is# 'k'
        if a:ctx.idx > 0
            let a:ctx.idx = a:ctx.idx - 1
        endif
    elseif a:key is# "\<cr>"
        return s:open_file(a:winid, 'e', a:ctx.files[a:ctx.idx])
    elseif a:key is# "\<c-v>"
        return s:open_file(a:winid, 'vnew', a:ctx.files[a:ctx.idx])
    elseif a:key is# "\<c-x>"
        return s:open_file(a:winid, 'new', a:ctx.files[a:ctx.idx])
    elseif a:key is# "\<c-t>"
        return s:open_file(a:winid, 'tabnew', a:ctx.files[a:ctx.idx])
    endif

    " それ以外は通常のpopup_filter_menuに渡す
    return popup_filter_menu(a:winid, a:key)
endfunction

function! s:open_file(winid, open, file) abort
    call popup_close(a:winid)
    execute a:open a:file
    return 1
endfunction
```

これで簡易ファイラーの実装は以上になります。

# まとめ
いかがだったでしょうか？合計で100行くらいで簡易ファイラーを実装できるVim script、便利ですね。
これをきっかけにプラグインを作ってみようってなってくれたら嬉しいなと思っています。

本記事のソースコードはリポジトリに置きましたので、全体像を掴めていない方は覗いてみてください。

https://github.com/skanehira/popupfiles.vim

なお、本記事はVim scriptの基本構文について触れていないので、詳しく知りたい方は`:h script`でヘルプを参照してください。