第八章 VimL 异步编程特性
8.4 使用配置内置终端
8.4.1 使用异步的两个方面
本章讨论的是 vim 的异步特性,其实这包含两个方面。其一如何利用 VimL 编程控制异 步任务,(写插件)实现特定的功能。前三节都是围绕这个话题的,从简单到复杂介绍了 vim 提供的三种异步机制,定时器、任务与通道。那可能有点抽象或晦涩,需要与具体的 插件功能结合起来才更好理解,但是基于本书的定位,也不便介绍与解读太复杂的插件。
其二是如何更好地使用 vim 新版本自身提供的异步功能,典型的就是内置终端。作为普 通用户,相对于开发,运用可能是更简单有趣的。本节就是打算跳出复杂的异步编程的曲 折过程,调剂一下,重新回到简单常规的 VimL 调教与定制内置终端,使之更符合个性习 惯,成为日常使用的利器。
当然,这依然是引导性质或经验之谈,详细文档请看 :help terminal
。
8.4.2 内置终端的启动
: terminal
: terminal bash
: terminal python
用 :terminal
命令开启内置终端。其实广义来讲,它可以接受外部命令参数,在内置 终端中运行任意的外部命令,譬如打开一个 python 解释器。默认无参数时就执行 &shell
指定的程序,比如 bash
。
不过一般地,我们提到内置终端,就是指狭义上的在 vim 里面运行一个 shell 。它会横 向分裂一个半屏窗口,在这个特殊的窗口就几乎与外面运行的 shell 一样的操作与功能 ,包括比如 .bashrc
的 shell 配置。
:terminal
除了可以指定外部命令参数外,还可以接受许多选项,控制诸如内置终端的 窗口大小、位置等各种选项。你可以将自己的偏好启动选项封装起来,自定义一个函数、 命令或快捷键。
此外,除了 vim 命令,还有个 vim 函数 term_star()
用于在编程逻辑中启动一个内 置终端,用法就如 job_start()
一样,给予灵活控制,按需启动终端。
8.4.3 终端模式的快捷键映射
在打开的内置终端窗口,为了能像外部 shell 那样使用 shell 本身的快捷键,Vim 禁用 了绝大部分快捷键。虽然在内置终端窗口中可以键入 shell 命令,但那不是 vim 的插入 模式也不是命令行模式,所以 imap
与 cmap
都不生效,当然更不可能是普通模式了 。 事实上,Vim 为此专门新定义了一种特殊模式,叫“终端任务”(Terminal-Job
)模 式,不妨简称终端模块。如果要为终端模式自定义快捷键,应该用 tmap
系列命令。
不过在动手之前,还是要了解 vim 已经保留了一个特殊键用来切换回 vim 的普通模式; 而且由于前叙原因,也仅保留了一个键。这个键由选项 &termwinkey
给出,默认也是 <C-W>
,因为它的本意正是如何使用 :wincmd
切出终端窗口。于是 <C-W>
引导的 快捷键在终端窗口与普通窗口保持一致的含义,并且附带两个扩展:
<C-W>w
切到下一窗口,<C-W>W
切到上一窗口,<C-W>p
切到之前所在窗口……<C-W>n
或<C-W><C-N>
终端窗口切到普通模式(可以用 hkl 移动了)<C-W>:
从终端窗口进入 vim 命令行,(否则按冒号只是在 shell 提示符后输入冒 号呢)
如果不喜欢 <C-W>
这个引导键——比如说因为 <C-W>
在 shell 中是删除前面一个词 的快捷键,故想将 <C-W>
键传给 shell ——那么可以设置 &termwinkey
更换。但一 般不建议修改,保持 Vim 内换窗口操作一致性较为重要,况且换任何键都可能会与 shell 冲突,总之是需要权衡。
从终端的任务模式回到普通模式略为麻烦,要按 <C-W><C-N>
(在已经按下 <C-W>
的情况下,<C-N>
多按或少按那个 ctrl
键差别不大了)。为什么不保留 <Esc>
键回到普通模式呢?大概是 vim 想兼容更多的终端,有些终端用 <Ecs>
作为转义符。 就个人使用经验而言,在 shell 中会经常用到 <Ecs>.
(接一个点)快捷键输入上一 条命令最后一个词。不过权衡之下,可以重定义 <Esc>
回到普通模式:
tnoremap <Esc> <C-\><C-N>
tnoremap <C-W>b <C-\><C-N><C-B>
tnoremap <C-W>n <C-W>:tabnext<CR>
tnoremap <C-W>N <C-W>:tabNext<CR>
tnoremap <C-W>1 <C-W>:1tabNext<CR>
tnoremap <C-W>2 <C-W>:2tabNext<CR>
...
除了 <Esc>
键外,我还定义了其他几个快捷键。比如使用终端时需要经常上翻查看结 果,就在 <C-W>
引导键后加个 b
,回到普通模式的同时上翻一页。然后我自己用 tabpage (标签页)比较多,所以也用 <C-W>
加数字切到特定的标签页中。当然,明 白了 tnoremap
之后,就能像 nnoremap
一样按自己习惯重定义快捷键了。
另外,按特定方式启动终端也可以自定义便用的快捷键,不过我推荐另一种思路,短命令 ,例如:
command! -nargs=* TT tab terminal <args>
command! -nargs=* TV vertical terminal <args>
这意思是用 :TT
命令在另一个标签页打开终端,用 :TV
按纵向分割窗口打开终端。 可以将其想象为 <mapleader>
是 :
,而且冒号本来就要按下 shift
键,再接一 两个大写字母也顺手,只不过最后还要多按 <CR>
回车确认执行命令。然而这另有个好 处是还可以随时增加其他命令行参数(传给 :terminal
),这种灵活性是普通模式下 的快捷键不能达成的。因此,“短命令”适合于替代那些“次常用”的快捷键,毕竟键盘布局 的快捷键资源以及个人的记忆习惯是有限的。
既然内置终端的启动方式可以定制,那么就想如何能在启动终端时才自动定义那些 tmap
快捷键呢?毕竟 tmap
在平时是用不上,也未必是每次打开 vim 都会用到内置终端, 将 tmap
(及其他与终端相关的设置)直接写在全局 vimrc
有点“浪费”。vim 显然 也想到了这个需求,很贴心地增加了一个自动命令事件,TerminalOpen
就会在打开内 置终端窗口时触发,于是可将如下事件写在某个合适的事件组(augroup
)中:
autocmd! TerminalOpen * call OnTermialOpen()
将你想要定制内置终端的代码都写在 OnTerminalOpen()
函数中,当然使用 #
形式 的自动加载函数会更好。
8.4.4 内置终端与 vim 交互
所谓交互,自然是分两方面的。其中从内置终端(的任务中)向 vim 发起交互的需求, 可能来自一个有趣的“哲学”问题:可不可以在内置终端中输入 $ vim file
再启一个 vim 编辑文件呢。那自然是可以的,但在实用中那显得有点愚蠢,不够优雅。于是,就需 要一个机制,从内置终端中向开启它的“宿主” vim 发送消息,令其打开某个文件。
于是 vim 就有了这么个约定(据说来自 emacs),在内置终端的运行的程序,只要向标 准输出打印如下序列:
<Esc>]51;["drop", "filenmae"]<07>
实际上就会将 ["drop", "filenmae"]
传递给宿主 vim ,然后 vim 就知道将该消息解 释为执行 :drop filename
命令。:drop
命令其实与 :edit
命令类似,就是打开 一个文件,只不过如果文件已被打开,就会跳到相应的目标窗口。:drop
命令也就是随 内置终端版本一起增加的,可见它的原意就是想解决这个痛点。
Esc
字符是终端的转义符,在 VimL 中固然可以用 <Esc>
表示,但在其他语言(如 C 语言)中,则一般用 \e
表示,或直接用其 ASCII 码( \x1B
或 \033
即十进 制的 27)表示。
例如,可以在 ~/bin/
目录下写个简单的 drop.sh
脚本:
#! /bin/bash
echo -e "\e]51;[\"drop\", \"$1\"]\x07"
注意传给 vim 的消息要求是 json 模式(见前一节的通道模式),drop
与文件名参数 须按 json 标准用双引号括起。在多数语言或脚本中如果用双引号括起整个序列字符串, 就得将里面的 json 字符串的双引号用 \"
转义。可以用其他任何语言写这个 drop 脚 本,例如等效的 perl 脚本(dorp.pl
)可以如下:
#! /usr/bin/env perl
my $filename = shift;
print qq{\x1B]51;["drop", "$filename"]\x07};
然后为了使用习惯,可以再在 ~/bin/
中建个 drop
软链接,指向实用的 drop 脚本 ,如:
$ chmod +x drop.pl
$ ln -s drop.pl drop
如果 ~/bin
在环境变量 PATH
中,则在 vim 的内置终端中,执行如下命令:
vim-shell $ drop file
就能在宿主 vim 中用 :drop
打开相应的文件。不过这还有个问题。我们在 shell 中 给任何命令输入文件名参数,一般都是当前目录下的文件名。但是 vim 内置终端的当前 目录,很可能与宿主 vim 的当前目录并不相同,于是 drop 命令可能会失效,所以在传 递消息中应该使用绝对路径,以保证能找到正确的文件。为此,可将原来的 ~/bin/drop.pl
改为如下:
#! /usr/bin/env perl
use Cwd 'abs_path';
my $filename = shift or die "usage: dorp filename";
my $filepath = abs_path($filename);
exec "vim $filepath" unless $ENV{VIM};
print qq{\x1B]51;["drop", "$filepath"]\x07};
主要改动是利用语言的相关模块获取文件绝对路径,并稍微保护判断下是否是否输入了文 件名参数。另一个改动是倒数第二行 exec ... unless
语句。只有在 vim 的内置终端 才会向宿主 vim 发 drop 消息,如果是从外部普通 shell 使用该脚本,那就会改为启动 vim (进程覆盖当前进程)打开命令行指定的文件,而最后一行再不没机会执行了。从 vim 中启动的内置终端会继承 vim 进程的环境变量,至少它会有 $VIM
这个环境变量 (可以用 :echo $VIM
查看),据此可以判断是内置终端还是外部终端。
当然,如果你熟悉 python ,用 python 写个 drop.py
也是容易的。
在 <Esc>51;[msg]<07>
转义序列中向 vim 传递的消息,除了支持 :dorp
命令,还 支持 :call
命令调用特殊的以 Tapi_
开头的自定义函数(限定函数名规范是为安全 起见)。消息形如 ["call", "Tapi_funcname", [argument-list]]
。自定义函数约定 接受两个参数,与内置终端窗口关联的 buffer 编号,以及一个参数,所以如果业务逻辑 需要多个参数,就只能将它们打包在一个列表或字典类型的变量,当作一个参数传入。 Vim 开放这么个接口提供灵活扩展的可能,具体能做什么那当然是用户的实现了。
8.4.5 vim 与内置终端交互
交互的另一方面,是 vim 向内置终端发消息。
显示,内置终端也是个任务,有着底层的通道,所以始终可以尝试使用上节介绍的 ch_sendraw()
等函数。然而对于内置终端,没必要使用这底层的函数,vim 提供更高 层函数 term_sendkeys()
直接向内置终端发送一个字符串,效果如同在终端提示符下 手动键入。注意该函数与 feedkeys()
的区别,后者是相当于向 vim 键入字符串,会 被 vim 截获,并受 tmap
映射影响;而前者是直接向内置终端键入,不受 tmap
影 响。
试验一下,在打开内置终端的窗口中,使用 <C-W>:
进入命令行,输入:
: call feedkeys('ls')
回车执行后,会在内置终端的提示符之后显示 ls
这两个字符,那就是相当于用户通过 vim 界面向内置终端敲了两个字符,但还没敲回车真正发送给内置终端运行。你可以继续 编辑这个命令,比如使用退格键删除之,或在其后增加选项 -l
,然后再按一次回车, 内置终端才能响应执行这个 ls
命令。然后,再 <C-W>:
试试输入:
: call term_sendkeys('', 'ls')
发现效果似乎还是一样,ls
这两字符停在内置终端提示符之后等待执行。需要将回车 键与合在这两个函数的参数中,才是通知内置终端立即执行:
: call feedkeys('ls' . "\<CR>")
: call term_sendkeys('', 'ls' . "\<CR>")
注意,回车键 <CR>
需要双引号转义。并且 term_sendkeys()
函数要求第一个参数 是指定内置终端的 buffer 编号,空值表示当前内置终端。从用户角度看,如果不涉及( 少量的)被 tmap
映射的键序列,用这两个函数的效果基本相同,但为了安全起见以及 语义明确,向内置终端发消息时,最好用 term_sendkeys()
函数。
在上一节介绍的 ZFVimTerminal 插件有个特性,是从 vim 的命令行中向模拟终端发送命 令。我们也可以借鉴这个思路,实现从 vim 命令行中向内置终端发送命令。当然了,从 内置终端窗口本身再用 <C-W>:
进入命令行输命令就有点多此一举了,反而麻烦。所以 需求应该是从任何一个普通 buffer 窗口,按 :
后在命令行向内置终端发送命令,避 免需要跳到内置终端的麻烦;当内置终端不存在时,显然应该打开一个新的内置终端。
为此,可以封装一个函数,并定义命令调用该函数,大致如下:
command! -nargs=* -bang TC call useterm#shell#SendShellCmd(<bang>0, <q-args>)
command! -nargs=* TCD call useterm#shell#SendShellCmd(0, 'cd ' . expand('%:p:h'))
function! useterm#shell#SendShellCmd(bang, cmd) abort
" save current window
if a:bang
let l:tab = tabpagenr()
let l:win = winnr()
endif
let l:found = useterm#shell#GotoTermWin(&shell)
if empty(l:found)
:terminal
endif
if !empty(a:cmd)
call term_sendkeys('', a:cmd . "\<CR>")
" into insert mode to force redraw terminal window
normal! i
endif
" back to origin window
if a:bang
if l:tab != 0 && l:tab != tabpagenr()
execute l:tab . 'tabnext'
endif
if l:win != 0 && l:win != winnr()
execute l:win . 'wincmd w'
endif
endif
endfunction "}}}
这里,仍然按短命令思想,定义 :TC
用于在内置终端中执行任意命令,就是将其参数 用 term_sendkes()
函数数转发给内置终端,并自动添加了回车键。:TC!
加叹号修 饰的话,会回到原来的普通窗口。用 :TCD
跳到内置终端窗口,并自动将内置终端的当 前目录切到原来编辑文件所在目录(就是自动执行 cd
命令啦)。因为 TCD
的用意 就是切到内置窗口,并开始在指定目录下与终端进行交互工作,那肯定是不必跳回原来的 ,所以传给实现函数的第一个参数写定为 0
。
查找并切到终端窗口的函数,这里不再列出,主要是通过 &buftype
选项值是否为 terminal
来判断。有兴趣的可以到这个地址查看详细代码: https://github.com/lymslive/autoplug/tree/master/autoload/useterm 。 如果不习惯短命令,或担心命名名冲突,尽可自行改自己觉得满意的足够长的命名名,或 者再定义个快捷键映射。