vim内置terminal的使用及实现
intro
在使用vim的时候,一个非常常用的功能就是搜索功能。
搜索
在缺少工程级别搜索的情况下,搜索通常不是一次完成的:通常的场景是提供一个最明显的关键字(并且忽略大小写),然后从结果中再缩小搜索范围,直到找到搜索结果。在这个迭代的过程中,可能需要从上次的输出中拷贝一部分、简单编辑之后再次搜索。这拷贝/编辑功能是vim的强项。如果能将命令的输出不断拷贝/粘贴/编辑是一个很好的功能。
耗时命令
在功能验证时,需要不断的构建和测试,这两个可能都是耗时比较长的操作,如果在vim中直接执行就不能执行vim操作,例如浏览代码。
另外,构建错误可能会输出错误文件的名字和行号,这就免不了需要打开这些文件。如果这些文件在一个vim的buffer中,直接通过gf就可以打开文件。
指令序列
通过git提交代码的时候,经常需要先执行git status查看修改了哪些文件,通过git diff查看修改的内容是会否符合预期,然后git add,git commit。
这样每次执行完之后再切换回vim跟平时的操作流并不一致,而程序员都是不喜欢这种不连续的切换状态。
vim提供的term功能,正好可以解决这些问题:启动一个终端,可有执行命令,并且可以进入多Normal模式下使用vim的编辑器功能。当然,前面的问题都不是只能使用term解决(通常也可以在cmd中启动bash来实现),只是通过这种方法解决看起来更加流畅和自然。
这个term功能尽管看起来并不太常用,但是这个功能在vim的源代码中出镜率还很高:在src文件夹下有专门的libvterm文件夹,这算是vim源代码中依赖的一个不太常规的外部库(readline算是比较常用的)。
在vim的主循环函数main_loop中
/*
* Main loop: Execute Normal mode commands until exiting Vim.
* Also used to handle commands in the command-line window, until the window
* is closed.
* Also used to handle ":visual" command after ":global": execute Normal mode
* commands, return when entering Ex mode. "noexmode" is TRUE then.
*/
void
main_loop(
int cmdwin, // TRUE when working in the command-line window
int noexmode) // TRUE when return on entering Ex mode
{
///...
/*
* If we're invoked as ex, do a round of ex commands.
* Otherwise, get and execute a normal mode command.
*/
if (exmode_active)
{
if (noexmode) // End of ":global/path/visual" commands
goto theend;
do_exmode(exmode_active == EXMODE_VIM);
}
else
{
#ifdef FEAT_TERMINAL
if (term_use_loop()
&& oa.op_type == OP_NOP && oa.regname == NUL
&& !VIsual_active
&& !skip_term_loop)
{
// If terminal_loop() returns OK we got a key that is handled
// in Normal mode. With FAIL we first need to position the
// cursor and the screen needs to be redrawn.
if (terminal_loop(TRUE) == OK)
normal_cmd(&oa, TRUE);
}
else
#endif
{
#ifdef FEAT_TERMINAL
skip_term_loop = FALSE;
#endif
normal_cmd(&oa, TRUE);
}
}
///...
"
libvterm
direction
一个直觉的疑问是:vim本身不就是在处理终端吗,为什么还需要一个vterm?这个其实就是term的两个方面:当vim作为一个编辑器时,它是一个term的使用者,只需要按照需求向终端输出控制命令(例如移动光标),而在vim中执行term命令时则是作为终端的输出方。这意味着需要解析类似移动光标/切换背景颜色之类的控制命令序列,
这一点并不是vim的主要功能,所以需要引入一个专门解析并执行终端控制命令的功能库。这个功能库接收并执行终端命令,并将处理之后的文字存储在特定位置,这样vim就可以直接使用处理后的内容,只需要将结果显示到buffer中即可。这里可以大哥不太恰当的比喻:libvterm就是一个电脑,可以将输入转换为屏幕像素信息,而vim作为一个显示器只需要按照像素信息显示即可。这也是真实/物理终端的功能:接收控制序列并展示在终端屏幕上。只是libvterm的作用是接收控制数列,将结果以数据结构的形式保存在内存中(可以供业务进程来取度)。
功能
- 回调
当使用libvterm创建一个终端时,需要提供vterm相关回调,其中最关键的是handle_damage,用来处理输出内容的变化。
static VTermScreenCallbacks screen_callbacks = {
handle_damage, // damage
handle_moverect, // moverect
handle_movecursor, // movecursor
handle_settermprop, // settermprop
handle_bell, // bell
handle_resize, // resize
handle_pushline, // sb_pushline
NULL, // sb_popline
NULL // sb_clear
};
- 变化内容
libvterm提供了获取屏幕上每个cell信息的接口。
/*
* Fill one screen line from a line of the terminal.
* Advances "pos" to past the last column.
*/
static void
term_line2screenline(
term_T *term,
win_T *wp,
VTermScreen *screen,
VTermPos *pos,
int max_col)
{
int off = screen_get_current_line_off();
for (pos->col = 0; pos->col < max_col; )
{
VTermScreenCell cell;
int c;
if (vterm_screen_get_cell(screen, *pos, &cell) == 0)
CLEAR_FIELD(cell);
///...
获取到的cell包括了字符内容(例如汉字的utf-8),文字及背景颜色,是否斜体/斜体/下划线等。有了这些信息,vim只负责展示即可。
typedef struct {
uint32_t chars[VTERM_MAX_CHARS_PER_CELL];
char width;
VTermScreenCellAttrs attrs;
VTermColor fg, bg;
} VTermScreenCell;
typedef struct {
unsigned int bold : 1;
unsigned int underline : 2;
unsigned int italic : 1;
unsigned int blink : 1;
unsigned int reverse : 1;
unsigned int conceal : 1;
unsigned int strike : 1;
unsigned int font : 4; /* 0 to 9 */
unsigned int dwl : 1; /* On a DECDWL or DECDHL line */
unsigned int dhl : 2; /* On a DECDHL line (1=top 2=bottom) */
unsigned int small : 1;
unsigned int baseline : 2;
} VTermScreenCellAttrs;
// VIM: this was a union, but that doesn't always work.
typedef struct {
/**
* Tag indicating which member is actually valid.
* Please use the `VTERM_COLOR_IS_*` test macros to check whether a
* particular type flag is set.
*/
uint8_t type;
uint8_t red, green, blue;
uint8_t index;
} VTermColor;
伪终端
在terminal的帮助文档中,有这么一段话
On Unix a pty is used to make it possible to run all kinds of commands. You
can even run Vim in the terminal! That's used for debugging, see below.
这里的伪终端(pty)有事什么用的呢?这个其实理解起来相对简单一些:它是libvterm的输入。在典型的运行shell的终端中,这些控制序列由bash生成,当然也可以由vim生成,或者任何进程向自己标准输出打印生成。
这就是文档里说的“You can even run Vim in the terminal!”(甚至还加了个惊叹号)的原因。
按键处理
谁处理输入
在vim的主循环中,会判断当前活跃的buffer是不是一个终端对应的buffer。全局变量curbuf保存当前(光标所在)buffer,而每个buffer结构中的b_term保存终端指针,如果buffer是由终端生成,则会指向对应终端对象。
* Returns TRUE if the current window contains a terminal and we are sending
* keys to the job.
* If "check_job_status" is TRUE update the job status.
*/
static int
term_use_loop_check(int check_job_status)
{
term_T *term = curbuf->b_term;
return term != NULL
&& !term->tl_normal_mode
&& term->tl_vterm != NULL
&& term_job_running_check(term, check_job_status);
}
/*
* Returns TRUE if the current window contains a terminal and we are sending
* keys to the job.
*/
int
term_use_loop(void)
{
return term_use_loop_check(FALSE);
}
此时,主流程执行转移到terminal_loop函数,并继续的等待/读取/处理用户的按键输入。
int
terminal_loop(int blocking)
{
while (blocking || vpeekc_nomap() != NUL)
{
///...
raw_c = term_vgetc();
///...
}
}
特殊按键
在term使用中,真正需要感兴趣的是哪些按键是特殊的。
从代码上看,在终端中真正有特殊意义的就是 ctrl-w(或者其他配置的termkey)或者ctrl-backslash引导的序列。由于ctrl-w刚好和bash的前向删除单个word冲突,所以这个需要注意下。从代码(和文档)可以看到,在terminal中通过 Ctrl -w .组合按键来向job发送Ctrl-W,并且还可以通过Ctrl-w “访问寄存器内容。
int
terminal_loop(int blocking)
{
///...
// Was either CTRL-W (termwinkey) or CTRL-\ pressed?
// Not in a system terminal.
if ((c == (termwinkey == 0 ? Ctrl_W : termwinkey) || c == Ctrl_BSL)
#ifdef FEAT_GUI
&& !curbuf->b_term->tl_system
#endif
)
{
int prev_c = c;
int prev_raw_c = raw_c;
int prev_mod_mask = mod_mask;
if (add_to_showcmd(c))
out_flush();
raw_c = term_vgetc();
c = raw_c_to_ctrl(raw_c);
clear_showcmd();
if (!term_use_loop_check(TRUE)
|| in_terminal_loop != curbuf->b_term)
// job finished while waiting for a character
break;
if (prev_c == Ctrl_BSL)
{
if (c == Ctrl_N)
{
// CTRL-\ CTRL-N : go to Terminal-Normal mode.
term_enter_normal_mode();
ret = FAIL;
goto theend;
}
// Send both keys to the terminal, first one here, second one
// below.
send_keys_to_term(curbuf->b_term, prev_raw_c, prev_mod_mask,
TRUE);
}
else if (c == Ctrl_C)
{
// "CTRL-W CTRL-C" or 'termwinkey' CTRL-C: end the job
mch_signal_job(curbuf->b_term->tl_job, (char_u *)"kill");
}
else if (c == '.')
{
// "CTRL-W .": send CTRL-W to the job
// "'termwinkey' .": send 'termwinkey' to the job
raw_c = ctrl_to_raw_c(termwinkey == 0 ? Ctrl_W : termwinkey);
}
else if (c == Ctrl_BSL)
{
// "CTRL-W CTRL-\": send CTRL-\ to the job
raw_c = ctrl_to_raw_c(Ctrl_BSL);
}
else if (c == 'N')
{
// CTRL-W N : go to Terminal-Normal mode.
term_enter_normal_mode();
ret = FAIL;
goto theend;
}
else if (c == '"')
{
term_paste_register(prev_c);
continue;
}
else if (termwinkey == 0 || c != termwinkey)
{
// space for CTRL-W, modifier, multi-byte char and NUL
char_u buf[1 + 3 + MB_MAXBYTES + 1];
// Put the command into the typeahead buffer, when using the
// stuff buffer KeyStuffed is set and 'langmap' won't be used.
buf[0] = Ctrl_W;
buf[special_to_buf(c, mod_mask, FALSE, buf + 1) + 1] = NUL;
ins_typebuf(buf, REMAP_NONE, 0, TRUE, FALSE);
ret = OK;
goto theend;
}
}
///...
}
outro
尽管很多人都不会用到vim的term功能,但是这些功能的代码在vim中却是实实在在存在的,并且影响了vim的代码结构和流程。了解这些功能更便于理解vim这个整体及其它相关模块的功能。
vim作虽然是一个无处不在的、徒手可得的编辑器,但实现并不简单。由于vim的大部分功能都是基于常见功能、基础设备实现,所以涉及到了不少设备的本质和用法,而且有不少优秀的设计/实现思路可以借鉴。