参考 Raw input and output,讲解的顺序和原教程不同,而且省略了很多关于转义序列的说明,键位也是模仿 Vi 来实现的。
这一步里,主要完成的功能有读取键盘的输入,键位映射和移动光标。
重构输入函数
为了处理更多更复杂的按键,我们需要写一个函数来专门读取键盘输入。将 step1 的代码作如下修改:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
#define CTRL_KEY(k) ((k)&0x1f)
char ed_read_Key() {
int nread;
char c;
// read() has a timeout, timeout return 0, so it loop read util a key press
// screen refresh after keypress
while ((nread = read(STDIN_FILENO, &c, 1)) != 1) {
if (nread == -1 && errno != EAGAIN) die("read");
}
return c;
}
/* input */
void ed_process_keypress() {
char c = ed_read_Key();
switch (c) {
case CTRL_KEY('q'):
exit(0);
break;
default:
if (iscntrl(c)) {
println("%d", c);
} else {
println("%d ('%c')", c, c);
}
break;
}
}
int main(int argc, char const *argv[]) {
enable_raw_mode();
while (1) {
ed_process_keypress();
}
return 0;
}
|
使用 while ((nread = read(STDIN_FILENO, &c, 1)) != 1)
来读取一个字符,因为超时的read()
会 return 0,所以会一直循环直到你按下一个按键。程序阻塞在这个循环中,直到按下按键。这样重写之后,我们的main()
函数变得简单多了。
同时使用#define CTRL_KEY(k) ((k)&0x1f)
这个宏,来获取CTRL-WORD
。我的另一篇博客)中有详细的解释,就不再重复。
刷新屏幕
我们在用户按下按键之后渲染我们想要的内容。先从清空屏幕开始。
清空屏幕
1
2
3
4
5
6
7
8
9
10
11
12
13
|
void ed_clear() {
// clear screen
write(STDOUT_FILENO, "\x1b[2J", 4);
// reposition cursor
write(STDOUT_FILENO, "\x1b[H", 3);
}
void ed_refresh() {
// clear screen
write(STDOUT_FILENO, "\x1b[2J", 4);
// reposition cursor
write(STDOUT_FILENO, "\x1b[H", 3);
}
|
这里我定义两个不同名的函数,目前它们的功能相同,只是清空屏幕而已。但是ed_refresh()
之后会添加更多功能,是渲染屏幕主要的地方,而ed_clear()
的功能只是清屏而已。
可以看到这里用到了转义序列,\x1b[2J
就是一个转义序列,它以\x1b
(27)开头。更多的信息可以参考这本 VT100 User Guide 。因为现代大多数的终端模拟器,都使用VT100
的转义序列。
\x1b[H
这个转义序列很有用,我们以后还会用到它。它可以移动光标的位置,这里我们把个光标移动到第一行第一列的位置。比如,你有一个 80(row)×40(col) 大小的终端,如果你想居中光标,你可以使用\x1b[12;40H
这个转义序列。注意它的下标从 1 开始。
如果我们想支持不同的终端,我们可以使用ncurses
库,它使用terminfo数据库来计算终端的功能以及用于该特定终端的转义序列。
退出时清空屏幕
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
void die(const char *msg) {
ed_clear();
perror(msg);
exit(1);
}
void ed_process_keypress() {
char c = ed_read_key();
switch (c) {
case CTRL_KEY('q'):
ed_clear();
exit(0);
break;
default:
break;
}
}
|
获取窗口大小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
typedef unsigned short win_size_t; // in vip.h
typedef struct editor_config {
struct termios origin_termios;
win_size_t winrows;
win_size_t wincols;
} Editor;
/**
* @brief get win rows and cols
* @retval return -1 when failed, return 0 when successd
*/
int get_winsize(win_size_t *rows, win_size_t *cols) {
struct winsize ws;
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0) {
return -1;
} else {
*rows = ws.ws_row;
*cols = ws.ws_col;
return 0;
}
}
/* init */
void init_editor() {
enable_raw_mode();
if (get_winsize(&editor.winrows, &editor.wincols) == -1) die("get_winsize");
}
int main(int argc, char const *argv[]) {
init_editor();
while (1) {
ed_refresh();
ed_process_keypress();
}
return 0;
}
|
ioctl()
,TIOCGWINSZ
和struct winsize
来自<sys / ioctl.h>
。这是在C中返回有多个值函数的一种常用写法,就是利用指针来修改参数的值,我一般把这种参数叫做receiver
,不知道准确不准确(同时还允许使用返回值来指示成功或失败。如果用 go 写的话了就爽了。)
使用typedef unsigned short win_size_t;
定义一个专用于窗口大小的数据类型,免得和其他类型混淆。
和其他类型的数据进行计算的时候需要注意,比如和int
类型一同运算,可以把 win_size_t
强制转为int
再进行运算,否则C默认会把int
转化为unsigned short
,有可能造成 unsigned 溢出。
获取光标位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
/**
* @brief send \x1b[6n to get cursor reply like \x1b[24;80R,
* then parse the reply.
*/
int get_cursor_pos(win_size_t *rows, win_size_t *cols) {
if (write(STDOUT_FILENO, "\x1b[6n", 4) != 4) return -1;
char buf[32];
unsigned int i = 0;
while (i < sizeof(buf) - 1) {
if (read(STDIN_FILENO, &buf[i], 1) != 1) break;
if (buf[i] == 'R') break;
i++;
}
buf[i] = '\0';
// println("\r\n&buf[1]: '%s'", &buf[1]);
if (buf[0] != '\x1b' || buf[1] != '[') return -1;
if (sscanf(&buf[2], "%hu;%hu", rows, cols) != 2) return -1;
return 0;
}
|
使用\x1b[6n
这个转义序列来获取光标的位置,之后终端会有回应。我们从标准输入读入字符,并且解析它。它和我们之前的光标位置的转义序列类似,只是最后一个字符变成了R
,即 Report。
再次获取窗口大小
ioctl()
不能保证能够在所有系统上都能正常返回窗口大小,因此我们将提供获取窗口大小的 fallback 方法。
我们可以将光标移动到右下角,然后获取光标的位置来作为窗口大小。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
int get_winsize(win_size_t *rows, win_size_t *cols) {
struct winsize ws;
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0) {
// move cursor to right 999 and move down to 999
// The C and B commands are specifically documented
// to stop the cursor from going past the edge of the screen.
if (write(STDOUT_FILENO, "\x1b[999C\x1b[999B", 12) != 12) return -1;
return get_cursor_pos(rows, cols);
} else {
*rows = ws.ws_row;
*cols = ws.ws_col;
return 0;
}
}
|
主要的解释都写在注释里了,当ioctl()
方法失败的时候,我们把光标移动到右下角,获取光标的位置作为窗口大小。不用担心光标的位置会出现在右下角,因为进入主循环后首先会刷新屏幕。
可以使用一个小技巧来测试这种情况,把第 3 行代码修改为:
1
|
if (1 || ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0)
|
测试完,记得改回来(
Append buffer
为了避免多次调用write()
来输出一小段的字符,我们可以用一个可变的字符串来拼接我们想要输出的字符,最后调用一次write()
来输入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// in vip.h
struct abuf {
char *b;
int len;
};
#define ABUF_INIT \
{ NULL, 0 }
/* append buffer */
void ab_append(struct abuf *ab, const char *s, int len) {
char *new = realloc(ab->b, ab->len + len);
if (new == NULL) return;
// appand s at end of new
memcpy(&new[ab->len], s, len);
ab->b = new;
ab->len += len;
}
void ab_free(struct abuf *ab) { free(ab->b); }
|
将ed_refresh()
中的write()
用ab_append()
代替:
1
2
3
4
5
6
7
8
9
10
|
void ed_refresh() {
struct abuf ab = ABUF_INIT;
ab_append(&ab, "\x1b[2J", 4); // clear entire screen`;
// reposition cursor
ab_append(&ab, "\x1b[H", 3);
write(STDOUT_FILENO, ab.b, ab.len);
ab_free(&ab);
}
|
渲染
现在,我们终于可以在屏幕上画点东西了。
VIP开始迈出第一步:首先来模仿一下 VIM 的欢迎界面吧(
波浪号
1
2
3
4
5
6
|
void ed_draw_rows(struct abuf *ab) {
for (int y = 0; y < editor.winrows; y++) {
ab_append(ab, "~", 1);
if (y < editor.winrows - 1) ab_append(ab, "\r\n", 2);
}
}
|
注意最后一行不需要打印\r\n
,如果打印的话,光标会移动到下一行,那么就会多处一行,多出的一行会导致终端滚动,这样我们的第一行被滚上去了(这不是我们想要的。
欢迎信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
// center a line, appand spaces in front of it
static inline void ed_draw_center(struct abuf *ab, int line_size) {
int margin = (editor.wincols - line_size) / 2;
while (margin--) {
ab_append(ab, " ", 1);
}
}
void ed_draw_rows(struct abuf *ab) {
for (int y = 0; y < editor.winrows; y++) {
ab_append(ab, "~", 1);
if (y == editor.winrows / 3) {
char buf[80];
int welcomelen = snprintf(
buf, sizeof(buf), "VIP Editor - Vi Poor - version %s", VIP_VERSION);
ed_draw_center(ab, welcomelen);
ab_append(ab, buf,
welcomelen > editor.wincols ? editor.wincols : welcomelen);
}
if (y == editor.winrows / 3 + 1) {
char buf[20];
int authorlen = snprintf(buf, sizeof(buf), "by LeeReindeer.");
ed_draw_center(ab, authorlen);
ab_append(ab, buf, authorlen);
}
if (y < editor.winrows - 1) ab_append(ab, "\r\n", 2);
}
}
|
ed_draw_center()
通过(editor.wincols - line_size) / 2
来计算偏移,并且在我们想要的打印的文字之前添加空格,来达到居中的效果。
渲染时隐藏光标
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
void ed_refresh() {
struct abuf ab = ABUF_INIT;
// hide cursor
abAppend(&ab, "\x1b[?25l", 6);
// clear entrie screen
abAppend(&ab, "\x1b[2J", 4);
// reposition cursor
abAppend(&ab, "\x1b[H", 3);
editorDrawRows(&ab);
abAppend(&ab, "\x1b[H", 3);
//show cursor
abAppend(&ab, "\x1b[?25h", 6);
write(STDOUT_FILENO, ab.b, ab.len);
abFree(&ab);
}
|
逐行清空
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
void ed_draw_rows(struct abuf *ab) {
for (int y = 0; y < editor.winrows; y++) {
ab_append(ab, "~", 1);
if (y == editor.winrows / 3) {
char buf[80];
int welcomelen = snprintf(
buf, sizeof(buf), "VIP Editor - Vi Poor - version %s", VIP_VERSION);
ed_draw_center(ab, welcomelen);
ab_append(ab, buf,
welcomelen > editor.wincols ? editor.wincols : welcomelen);
}
if (y == editor.winrows / 3 + 1) {
char buf[20];
int authorlen = snprintf(buf, sizeof(buf), "by LeeReindeer.");
ed_draw_center(ab, authorlen);
ab_append(ab, buf, authorlen);
}
// erases the part of the line to the right of the cursor.
ab_append(ab, "\x1b[K", 3);
if (y < editor.winrows - 1) ab_append(ab, "\r\n", 2);
}
}
void ed_refresh() {
struct abuf ab = ABUF_INIT;
// hide cursor
ab_append(&ab, "\x1b[?25l", 6);
//ab_append(&ab, "\x1b[2J", 4); // clear entire screen`;
// reposition cursor
ab_append(&ab, "\x1b[H", 3);
ed_draw_rows(&ab);
ab_append(&ab, "\x1b[H", 3);
// hide cursor
ab_append(&ab, "\x1b[?25h", 6);
write(STDOUT_FILENO, ab.b, ab.len);
ab_free(&ab);
}
|
注释掉e_refresh
中的ab_append(&ab, "\x1b[2J", 4);
一行,这一行输出的转义序列可以清空整个屏幕。我们换一种做法,在ed_draw_rows()
方法的循环中,加上ab_append(ab, "\x1b[K", 3);
来清空这一行光标右边的内容。
移动光标
在 Vim 里,可以使用h,j,k,l
和方向键来移动光标。VIP 里的键位和 Vim 基本一致,功能也尽量的模仿 Vim 。
使用两个全局变量(cx,cy)来保存光标的坐标:
1
2
3
4
5
6
7
8
|
typedef struct editor_config {
win_size_t cx, cy;
win_size_t winrows;
win_size_t wincols;
enum EditorMode mode; // NORMAL_MODE, INSERT_MODE
struct termios origin_termios;
} Editor;
|
模仿Vi的键位
定义键位的enum
:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
enum EditorKey {
ARROW_LEFT = 1000,
ARROW_RIGHT = 1001,
ARROW_UP = 1002,
ARROW_DOWN = 1003,
LEFT = 'h',
RIGHT = 'l',
UP = 'k',
DOWN = 'j',
LINE_START = '0',
LINE_END = '$',
};
|
和 Vim 一样,不止可以使用hjkl
来移动,同时还可以使用方向键来移动。由于方向键映射多个 ASCII 码。这里使用大于127
的数字来表示它们(这样就不会和 ASCII 码冲突了)。
LINE_START
和LINE_END
在这里只是简单的跳到屏幕起始和屏幕末尾处。
模式初步
1
2
3
4
5
6
|
enum EditorMode { NORMAL_MODE = 0, INSERT_MODE };
enum EditorKey {
//...
INSERT_MODE_KEY = 'i',
NORMAL_MODE_KEY = '\x1b'
};
|
Vim 里有六个模式?目前先简单的定义两个模式:NORMAL 和 INSERT。
在 NORMAL 下可以同时使用hjkl
和方向键来移动光标,在 INSERT 模式下当然只能使用方向键来移动光标。
1
2
3
4
5
6
7
8
|
void ed_process_keypress() {
int key = ed_read_key();
if (editor.mode == INSERT_MODE) {
ed_insert_process(key);
} else if (editor.mode == NORMAL_MODE) {
ed_normal_process(key);
}
}
|
以上代码根据不同的模式来处理按键。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
void ed_normal_process(int c) {
switch (c) {
case INSERT_MODE_KEY:
editor.mode = INSERT_MODE;
break;
case CTRL_KEY('q'):
ed_clear();
exit(0);
break;
case LEFT:
case ARROW_LEFT:
case RIGHT:
case ARROW_RIGHT:
case DOWN:
case ARROW_DOWN:
case UP:
case ARROW_UP:
case HOME_KEY:
case LINE_START:
case END_KEY:
case LINE_END:
ed_process_move(c);
default:
break;
}
}
|
在 NORMAL 模式下,按下i
可以变成 INSERT 模式。这里把方向键和hjkl
的还有其他移动光标的键委托给ed_process_move()
函数来处理。同样的,在 INSERT 模式下,按下Esc
可以变成 NORMAL 模式,对与移动光标的按键也委托给ed_process_move()
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
void ed_insert_process(int c) {
switch (c) {
case NORMAL_MODE_KEY:
editor.mode = NORMAL_MODE;
break;
case ARROW_DOWN:
case ARROW_UP:
case ARROW_LEFT:
case ARROW_RIGHT:
case HOME_KEY:
case END_KEY:
ed_process_move(c);
break;
default:
break;
}
}
|
小结
_(:зゝ∠)_现在我们有了一个欢迎界面,还可以 play with cursor。现在,VIP只是徒有其表而已,没有编辑器的功能。文本编辑器可以简单的分为两个功能:查看和编辑。下一步,先实现一下带行号的文本查看器。
这一步的代码:step2。