【(Neo)Vim】terminal mode のマッピングについての闇とその解決法

# 最新の Neo(Vim) では端末が使える

Neovim、又は Vim8 にはそれぞれ `:terminal` というものがあります。これさえあればエディタの中から端末の操作ができてすんごい捗るんです！

……っていうと他のエディタ勢からは「21 世紀にもなって何言ってんだこいつ」ってなるんですがまあそれは置いといて。

# モードの切替が大変なことに

(Neo)Vim には他のエディタにない**モード**という概念があります。この記事読む方には百も承知なことだと思うので詳細は省きますが、`:terminal` が追加されたことによってモードの切替がますます複雑になってしまいました。

* 通常のウィンドウ

| モード         | 切替                         |
|----------------|------------------------------|
| ノーマルモード | `i` で挿入モードに。[^1]     |
| 挿入モード     | `<Esc>` でノーマルモードに。 |

[^1]: 挿入モードへの切替は `i` 以外にもたくさんありますが省略します。

* ターミナルウィンドウ

| モード                 | 切替                                       |
|------------------------|--------------------------------------------|
| Terminal-Normal モード | `i` で Terminal-Job モードに。             |
| Terminal-Job モード    | `<C-\><C-n>` で Terminal-Normal モードに。 |

という違いがあります。ここで **Terminal-Normal モード**というのは Neovim の場合、`:terminal` したすぐ後の状態で、自由にカーソルを動かせますがシェルに入力出来ません。`i` を押すことで通常のウィンドウと同じようにシェルへ入力が出来る **Terminal-Job モード**に移ることが出来ます。

Vim ではこれが反対で、`:terminal` で端末を開いたすぐ後に **Terminal-Job** モードになります。**Terminal-Normal モード**はそこから `<C-\><C-n>` で切り替えた状態です。

# 【Vim のみ】端末ウィンドウにおける `<C-w>` の扱い

この節だけは Neovim に関係がありません。Vim では Neovim に遅れて端末機能が実装された際、ちょっとお節介な機能が付け足されていまして、Terminal-Job モードで `<C-w>` というマッピングに特別な意味を持たせました。いくつかの機能があるのですが、特に大事なのは `<C-w>w` などの他のウィンドウに移るためのマッピングです。

ところが、シェル使いの人なら知っての通り `<C-w>` というキーは非常に重要です。カーソル位置からその前の単語を消してくれるヤツ[^2]ですね。単語を消したかっただけなのに `<C-w>` 押したらうんともすんとも言わなくなってストレスが溜まるんですよね。

[^2]: [Bash Reference Manual: Commands For Killing][bash] によると、`unix-filename-rubout` という名称です。

[bash]: https://www.gnu.org/software/bash/manual/html_node/Commands-For-Killing.html

一応これを解決するため `'termkey'` というオプションが用意されています。

```vim
set termkey=<C-l>
```

このようにすると **Terminal-Job モードでの**`<C-w>` のマッピングが `<C-l>` に変わります。でもねえ、これじゃダメなんですよ。ウィンドウ間の移動なんて一番頻繁に使う操作じゃないですか（当社比）。

* 各モードでウィンドウ間を移動するためのマッピング

| ウィンドウ             | マッピング    |
|------------------------|---------------|
| ノーマルモード         | `<C-w>w`      |
| 挿入モード             | `<Esc><C-w>w` |
| Terminal-Normal モード | `<C-w>w`      |
| Terminal-Job モード    | `<C-l>w`      |

ウィンドウ間を移動するとき、イチイチ端末ウィンドウかどうか考えて別のマッピング使うなんてやってられませんよ。とはいえ `<C-w>` がシェルで使えなくなるのは業腹なので `'termkey'` だけは適切に（つまりシェル操作を邪魔しないように）設定しておいて、このマッピングは忘れてしまいましょう。

# Terminal-Job モードからノーマルモードの機能を使う

ここからは又 (Neo)Vim 共通の話です。先ほど挙げました「端末から別のウィンドウへ移動する」機能について（`'termkey'` のことを忘れて）もう一度表にまとめますと次のようになります。

* 各モードでウィンドウ間を移動するためのマッピング

| ウィンドウ             | マッピング         |
|------------------------|--------------------|
| ノーマルモード         | `<C-w>w`           |
| 挿入モード             | `<Esc><C-w>w`      |
| Terminal-Normal モード | `<C-w>w`           |
| Terminal-Job モード    | `<C-\><C-n><C-w>w` |

Terminal-Job モードでは `<C-\><C-n>` で一度 Terminal-Normal モードに移動してから `<C-w>w` してるんですね。しかしこれもまた、**イチイチ端末ウィンドウかどうか考えて別のマッピング使う**愚に陥っており、お世辞にも直感的とは言えません。

一つの解決法として、Terminal-Job モードのマッピング（つまりシェルの入力中のキー操作）で `<Esc>` を `<C-\><C-n>` に割り当てる方法が良く提案されています（ヘルプにも記載があります）。

```vim
tnoremap <Esc> <C-\><C-n>
```

`:tnoremap` は Terminal-Job モード中のキー操作に別の機能を割り当てられるものです。`<Esc>` 押したら `<C-\><C-n>` と同じ動きするんですね。

* 各モードでウィンドウ間を移動するためのマッピング（`<Esc>` のマッピングを変えた場合）

| ウィンドウ             | マッピング    |
|------------------------|---------------|
| ノーマルモード         | `<C-w>w`      |
| 挿入モード             | `<Esc><C-w>w` |
| Terminal-Normal モード | `<C-w>w`      |
| Terminal-Job モード    | `<Esc><C-w>w` |

これならそんなに違和感がありません。端末ウィンドウと通常のウィンドウで操作が同じですね。めでたしめでたし。

**ではないんですよ！**

# シェルの vi mode の存在

> [bashとzshでviのキーバインドを使用する - builder by ZDNet Japan](https://builder.japan.zdnet.com/os-admin/20372652/)

bash や zsh など、大方のシェルには **Emacs mode**, **Vi mode** の双方が用意ありまして、**Vimmer なら端末も vi キーバインドで使っている**ことが（僕の中では）常識になっているのです。`<Esc>` はシェルにおいても重要なんですね。これを加味すると、先ほどの表は更に複雑になります。

| モード                  | シェルのモード           | 切替                                       |
|-------------------------|--------------------------|--------------------------------------------|
| ノーマルモード          |                          | `i` で挿入モードに。                       |
| 挿入モード              |                          | `<Esc>` でノーマルモードに。               |
| Terminal-Normal モード  |                          | `i` で Terminal-Job モードに。             |
| Terminal-Job モード     |                          | `<C-\><C-n>` で Terminal-Normal モードに。 |
| （Terminal-Job モード） | （シェル）ノーマルモード | `i` でシェルの挿入モードに。               |
| （Terminal-Job モード） | （シェル）挿入モード     | `<Esc>` でシェルのノーマルモードに。       |

`<Esc>` を `<C-\><C-n>` にマッピングしていると、**（シェル）挿入モード**から**（シェル）ノーマルモード**に移ることが出来なくなってしまいます。これは不便。`<Esc>` と `<C-\><C-n>` の双方の使い分けが大事なのです。

# Alt (Option) キーを使おう

そこで考えられるのは**全てのモードで他に意味の無いマッピングを使うこと**です。<kbd>Alt</kbd> キー、あるいは <kbd>Option</kbd> キーはこの用途にうってつけです。(Neo)Vim ではこのために `<A-` という prefix が使えます。

例えば、度々例に出てきた `<C-w>w` を全てのモードで共通のマッピングに割り当ててしまいましょう。

```vim
noremap  <A-j> <C-w>w
inoremap <A-j> <Esc><C-w>w
tnoremap <A-j> <C-\><C-n><C-w>w
```

このようにすると以下のように全ての場合に同じマッピングが使えます。

* 各モードでウィンドウ間を移動するためのマッピング（`<A-` を使った場合）

| ウィンドウ             | マッピング |
|------------------------|------------|
| ノーマルモード         | `<A-j>`    |
| 挿入モード             | `<A-j>`    |
| Terminal-Normal モード | `<A-j>`    |
| Terminal-Job モード    | `<A-j>`    |

勿論これは `<C-w>w` 以外のマッピングにも有効です。例えば、以下のような機能を `<A-` で置き換えると便利です[^3]。

```vim
" ウィンドウ間を逆に移動
noremap  <A-k> <C-w>W
inoremap <A-k> <Esc><C-w>W
tnoremap <A-k> <C-\><C-n><C-w>W
" 他のウィンドウを閉じて最大化する
noremap  <A-o> <C-w>o
inoremap <A-o> <Esc><C-w>o
tnoremap <A-o> <C-\><C-n><C-w>o
" コマンドラインモードに移行（これは英語キーボードの場合です）
noremap  <A-;> :
inoremap <A-;> <Esc><C-o>:
tnoremap <A-;> <C-\><C-n><C-w>:
" 検索に移行
noremap  <A-/> /
inoremap <A-/> <Esc><C-o>/
tnoremap <A-/> <C-\><C-n>/
```

[^3]: 一部の `:inoremap` に `<C-o>` というマッピングが挟まっていますが、これは**挿入ノーマルモード**への移行を表しています。詳しくはヘルプ（`:h i_CTRL-O`）を読んでください。

## `<A-` を認識しない場合

一部の端末アプリでは (Neo)Vim 上で <kbd>Alt</kbd>（<kbd>Option</kbd>）によるキー操作をうまく認識しない場合があります。いろいろな原因があるので難しいのですが、一つの解決法としては、実際に挿入モードで <kbd>Option</kbd>+<kbd>J</kbd> などを押して、出てくる文字にマッピングしてしまうことです。

```vim
" ê は <A-j> を押したら出てくる文字
noremap ê <C-w>w
"noremap  <A-j> <C-w>w
```

たいていの場合はこれでうまく行きます。

# ウィンドウを移動した際、自動的に Terminal-Job モードに入る

これでだいぶ理想の状態に近づきましたが、端末ウィンドウに移動するのは多くの場合、シェルのコマンドを使いたいときですよね？ 普通に `<C-w>w` で移動すると Terminal-Normal モードに入ってしまうのでわざわざ <kbd>I</kbd> を押さないとシェルに移れません。

これは以下のように設定すれば解決です。

```vim
if has('nvim')
  " Neovim 用
  autocmd WinEnter * if &buftype ==# 'terminal' | startinsert | endif
else
  " Vim 用
  autocmd WinEnter * if &buftype ==# 'terminal' | normal i | endif
endif
```

移動した先のウィンドウが端末ウィンドウならば自動的に挿入モード（正確には Terminal-Job モード）へ移行しています。`:startinsert` はなぜか Vim の端末ウィンドウで認識しなかったので `:normal i` で直接 `i` を入力させることにしました。

# 終わりに

(Neo)Vim の、特に Vim の端末機能はまだまだ発展途上です。まだ自分でも試行錯誤中ですが、便利な利用法などありましたらコメントなどで指摘していただけると助かります。