参考 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()TIOCGWINSZstruct 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 的欢迎界面吧(

vim-welcome

波浪号

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来计算偏移,并且在我们想要的打印的文字之前添加空格,来达到居中的效果。

vip-welcome

渲染时隐藏光标

 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_STARTLINE_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