文本查看器(第四节)

行查看器

让我们在编辑器中创建一个数据类型来存储一行文本。

kilo.c

步骤55:定义erow结构体

/*** 包含的头文件 ***/
/*** 宏定义 ***/
/*** 数据 ***/
typedef struct erow {
  int size;
  char *chars;
} erow;
struct editorConfig {
  int cx, cy;
  int screenrows;
  int screencols;
  int numrows;
  erow row;
  struct termios orig_termios;
};
struct editorConfig E;
/*** 终端相关函数 ***/
/*** 追加缓冲区 ***/
/*** 输出处理函数 ***/
/*** 输入处理函数 ***/
/*** 初始化函数 ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.numrows = 0;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
}
int main() { … }

♎︎ 可编译,但无明显效果

erow 代表 “editor row”(编辑器行),它将一行文本存储为一个指向动态分配字符数据的指针以及一个长度。typedef 让我们可以用 erow 来指代这个类型,而不必使用 struct erow

我们在编辑器的全局状态中添加了一个 erow 值,

扫描二维码关注微信公众号,回复密码,即可获取密码
以及一个 numrows 变量。目前,编辑器将只显示一行文本,所以 numrows 可以是 0 或 1。我们在 initEditor() 函数中将其初始化为 0。

现在让我们用一些文本填充那个 erow。我们暂时不考虑从文件中读取内容。相反,我们将把一个 “Hello, world” 字符串硬编码到其中。

kilo.c

步骤56:显示“Hello, world”

/*** 包含的头文件 ***/
#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <termios.h>
#include <unistd.h>
/*** 宏定义 ***/
/*** 数据 ***/
/*** 终端相关函数 ***/
void die(const char *s) { … }
void disableRawMode() { … }
void enableRawMode() { … }
int editorReadKey() { … }
int getCursorPosition(int *rows, int *cols) { … }
int getWindowSize(int *rows, int *cols) { … }
/*** 文件输入/输出函数 ***/
void editorOpen() {
  char *line = "Hello, world!";
  ssize_t linelen = 13;
  E.row.size = linelen;
  E.row.chars = malloc(linelen + 1);
  memcpy(E.row.chars, line, linelen);
  E.row.chars[linelen] = '\0';
  E.numrows = 1;
}
/*** 追加缓冲区 ***/
/*** 输出处理函数 ***/
/*** 输入处理函数 ***/
/*** 初始化函数 ***/
void initEditor() { … }
int main() {
  enableRawMode();
  initEditor();
  editorOpen();
  while (1) {
    editorRefreshScreen();
    editorProcessKeypress();
  }
  return 0;
}

♎︎ 可编译,但无明显效果

malloc() 函数来自 <stdlib.h> 头文件。ssize_t 类型来自 <sys/types.h> 头文件。

editorOpen() 函数最终将用于从磁盘打开和读取文件,所以我们把它放在一个新的 /*** file i/o ***/(文件输入/输出)部分中。为了将我们的 “Hello, world” 消息加载到编辑器的 erow 结构体中,我们将 size 字段设置为消息的长度,使用 malloc() 分配必要的内存,然后使用 memcpy() 将消息复制到指向我们分配内存的 chars 字段中。最后,我们将 E.numrows 变量设置为 1,以表明 erow 现在包含了一行应该显示的文本。

那我们来显示它吧。

kilo.c

步骤57:绘制erow行

/*** 包含的头文件 ***/
/*** 宏定义 ***/
/*** 数据 ***/
/*** 终端相关函数 ***/
/*** 文件输入/输出函数 ***/
/*** 追加缓冲区 ***/
/*** 输出处理函数 ***/
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    if (y >= E.numrows) {
      if (y == E.screenrows / 3) {
        char welcome[80];
        int welcomelen = snprintf(welcome, sizeof(welcome),
          "Kilo editor -- version %s", KILO_VERSION);
        if (welcomelen > E.screencols) welcomelen = E.screencols;
        int padding = (E.screencols - welcomelen) / 2;
        if (padding) {
          abAppend(ab, "~"1);
          padding--;
        }
        while (padding--) abAppend(ab, " "1);
        abAppend(ab, welcome, welcomelen);
      } else {
        abAppend(ab, "~"1);
      }
    } else {
      int len = E.row.size;
      if (len > E.screencols) len = E.screencols;
      abAppend(ab, E.row.chars, len);
    }
    abAppend(ab, "\x1b[K"3);
    if (y < E.screenrows - 1) {
      abAppend(ab, "\r\n"2);
    }
  }
}
void editorRefreshScreen() { … }
/*** 输入处理函数 ***/
/*** 初始化函数 ***/

♐︎ 可编译

我们将之前绘制行的代码包裹在一个 if 语句中,该语句检查我们当前绘制的行是文本缓冲区中的一行,还是文本缓冲区末尾之后的一行。

要绘制文本缓冲区中的一行,我们只需写出 erow 的 chars 字段。但首先,如果渲染的行超过屏幕的末尾,我们要注意截断它。

接下来,让我们允许用户打开一个实际的文件。我们将读取并显示文件的第一行。

kilo.c

步骤58:打开文件

/*** 包含的头文件 ***/
/*** 宏定义 ***/
/*** 数据 ***/
/*** 终端相关函数 ***/
/*** 文件输入/输出函数 ***/
void editorOpen(char *filename) {
  FILE *fp = fopen(filename, "r");
  if (!fp) die("fopen");
  char *line = NULL;
  size_t linecap = 0;
  ssize_t linelen;
  linelen = getline(&line, &linecap, fp);
  if (linelen != -1) {
    while (linelen > 0 && (line[linelen - 1] == '\n' ||
                           line[linelen - 1] == '\r'))
      linelen--;
    E.row.size = linelen;
    E.row.chars = malloc(linelen + 1);
    memcpy(E.row.chars, line, linelen);
    E.row.chars[linelen] = '\0';
    E.numrows = 1;
  }
  free(line);
  fclose(fp);
}
/*** 追加缓冲区 ***/
/*** 输出处理函数 ***/
/*** 输入处理函数 ***/
/*** 初始化函数 ***/
void initEditor() { … }
int main(int argc, char *argv[]) {
  enableRawMode();
  initEditor();
  if (argc >= 2) {
    editorOpen(argv[1]);
  }
  while (1) {
    editorRefreshScreen();
    editorProcessKeypress();
  }
  return 0;
}

♋︎ 可能编译通过,也可能不通过

FILE 类型、fopen() 函数和 getline() 函数都来自 <stdio.h> 头文件。

editorOpen() 函数的核心部分是相同的,我们现在只是从 getline() 函数获取行和行长度的值,而不是使用硬编码的值。

editorOpen() 函数现在接受一个文件名,并使用 fopen() 函数打开该文件进行读取。我们通过检查用户是否将文件名作为命令行参数传递来允许用户选择要打开的文件。如果他们传递了,我们就调用 editorOpen() 函数并将文件名传递给它。如果他们运行 ./kilo 时没有参数,editorOpen() 函数将不会被调用,他们将从一个空白文件开始。

当我们不知道为每一行分配多少内存时,getline() 函数对于从文件中读取行很有用。它会为你处理内存管理。首先,我们向它传递一个空的行指针和一个 linecap(行容量)为 0。这使得它为读取的下一行分配新的内存,并将 line 设置为指向该内存,并设置 linecap 让你知道它分配了多少内存。它的返回值是读取的行的长度,如果到达文件末尾且没有更多行可读,则返回 -1。以后,当我们让 editorOpen() 函数读取文件的多行时,我们将能够反复将新的行和 linecap 值反馈给 getline() 函数,并且只要 linecap 足够大以容纳下一行,它就会尝试重用 line 指向的内存。目前,我们只是将它读取的一行复制到 E.row.chars 中,然后使用 free() 释放 getline() 分配的 line 内存。

在将行复制到我们的 erow 之前,我们还会去除行末尾的换行符或回车符。我们知道每个 erow 代表一行文本,所以在每一行末尾存储一个换行符是没有用的。
如果你的编译器对getline()函数报错,你可能需要定义一个特性测试宏。即便在你的机器上代码不使用这些宏也能顺利编译,我们还是把它们加上,让我们的代码更具可移植性。

kilo.c
步骤59
特性测试宏

/*** 包含文件 ***/
#define _DEFAULT_SOURCE
#define _BSD_SOURCE
#define _GNU_SOURCE
#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <termios.h>
#include <unistd.h>
/*** 定义 ***/
/*** 数据 ***/
/*** 终端操作 ***/
/*** 文件输入/输出 ***/
/*** 追加缓冲区 ***/
/*** 输出 ***/
/*** 输入 ***/
/*** 初始化 ***/

♐︎ 已编译
我们把这些宏定义在包含文件语句的上方,因为我们所包含的头文件会利用这些宏来决定公开哪些特性。

现在我们来快速修复一个小错误。我们希望欢迎信息仅在用户启动程序时不传入任何参数的情况下显示,而不是在他们打开文件时显示,因为欢迎信息可能会干扰文件内容的显示。

kilo.c
步骤60
隐藏欢迎信息

/*** 包含文件 ***/
/*** 定义 ***/
/*** 数据 ***/
/*** 终端操作 ***/
/*** 文件输入/输出 ***/
/*** 追加缓冲区 ***/
/*** 输出 ***/
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    if (y >= E.numrows) {
      if (E.numrows == 0 && y == E.screenrows / 3) {
        char welcome[80];
        int welcomelen = snprintf(welcome, sizeof(welcome),
          "Kilo编辑器——版本 %s", KILO_VERSION);
        if (welcomelen > E.screencols) welcomelen = E.screencols;
        int padding = (E.screencols - welcomelen) / 2;
        if (padding) {
          abAppend(ab, "~"1);
          padding--;
        }
        while (padding--) abAppend(ab, " "1);
        abAppend(ab, welcome, welcomelen);
      } else {
        abAppend(ab, "~"1);
      }
    } else {
      int len = E.row.size;
      if (len > E.screencols) len = E.screencols;
      abAppend(ab, E.row.chars, len);
    }
    abAppend(ab, "\x1b[K"3);
    if (y < E.screenrows - 1) {
      abAppend(ab, "\r\n"2);
    }
  }
}
void editorRefreshScreen() { … }
/*** 输入 ***/
/*** 初始化 ***/

♐︎ 已编译
好了,现在欢迎信息只会在文本缓冲区完全为空时才会显示。

多行内容
为了存储多行内容,我们将E.row设为erow结构体的数组。这将是一个动态分配的数组,所以我们把它定义为指向erow的指针,并将该指针初始化为NULL。(这会导致我们现有的很多代码出错,因为这些代码并未预料到E.row会是一个指针,所以在接下来的几个步骤中程序将无法编译通过。)

kilo.c
步骤61
erow数组

/*** 包含文件 ***/
/*** 定义 ***/
/*** 数据 ***/
typedef struct erow { … } erow;
struct editorConfig {
  int cx, cy;
  int screenrows;
  int screencols;
  int numrows;
  erow *row;
  struct termios orig_termios;
};
struct editorConfig E;
/*** 终端操作 ***/
/*** 文件输入/输出 ***/
/*** 追加缓冲区 ***/
/*** 输出 ***/
/*** 输入 ***/
/*** 初始化 ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.numrows = 0;
  E.row = NULL;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
}
int main(int argc, char *argv[]) { … }

♏︎ 无法编译
接下来,我们把editorOpen()函数中初始化E.row的代码移到一个新的函数editorAppendRow()中。我们还会把它放在一个新的代码段下,即/*** 行操作 ***/

kilo.c
步骤62
追加行

/*** 包含文件 ***/
/*** 定义 ***/
/*** 数据 ***/
/*** 终端操作 ***/
void die(const char *s) { … }
void disableRawMode() { … }
void enableRawMode() { … }
int editorReadKey() { … }
int getCursorPosition(int *rows, int *cols) { … }
int getWindowSize(int *rows, int *cols) { … }
/*** 行操作 ***/
void editorAppendRow(char *s, size_t len) {
  E.row.size = len;
  E.row.chars = malloc(len + 1);
  memcpy(E.row.chars, s, len);
  E.row.chars[len] = '\0';
  E.numrows = 1;
}
/*** 文件输入/输出 ***/
void editorOpen(char *filename) {
  FILE *fp = fopen(filename, "r");
  if (!fp) die("fopen");
  char *line = NULL;
  size_t linecap = 0;
  ssize_t linelen;
  linelen = getline(&line, &linecap, fp);
  if (linelen != -1) {
    while (linelen > 0 && (line[linelen - 1] == '\n' ||
                           line[linelen - 1] == '\r'))
      linelen--;
    editorAppendRow(line, linelen);
  }
  free(line);
  fclose(fp);
}
/*** 追加缓冲区 ***/
/*** 输出 ***/
/*** 输入 ***/
/*** 初始化 ***/

♏︎ 无法编译
注意,我们把linelinelen变量重命名为slen,它们现在成了editorAppendRow()函数的参数。

我们希望editorAppendRow()函数为一个新的erow结构体分配空间,然后将给定的字符串复制到E.row数组末尾的新erow结构体中。我们现在就来实现这一点。

kilo.c
步骤63
修复追加行函数

/*** 包含文件 ***/
/*** 定义 ***/
/*** 数据 ***/
/*** 终端操作 ***/
/*** 行操作 ***/
void editorAppendRow(char *s, size_t len) {
  E.row = realloc(E.row, sizeof(erow) * (E.numrows + 1));
  int at = E.numrows;
  E.row[at].size = len;
  E.row[at].chars = malloc(len + 1);
  memcpy(E.row[at].chars, s, len);
  E.row[at].chars[len] = '\0';
  E.numrows++;
}
/*** 文件输入/输出 ***/
/*** 追加缓冲区 ***/
/*** 输出 ***/
/*** 输入 ***/
/*** 初始化 ***/

♏︎ 无法编译
我们必须告知realloc()函数我们想要分配多少字节的空间,所以我们用每个erow结构体占用的字节数(sizeof(erow))乘以我们所需的行数。然后我们将at设置为我们想要初始化的新行的索引,并将E.row的每一处引用替换为E.row[at]。最后,我们把E.numrows = 1改为E.numrows++

接下来,当打印当前行时,我们更新editorDrawRows()函数,让它使用E.row[y]而不是E.row

kilo.c
步骤64

绘制多个erow结构体
/*** 包含文件 ***/
/*** 定义 ***/
/*** 数据 ***/
/*** 终端操作 ***/
/*** 行操作 ***/
/*** 文件输入/输出 ***/
/*** 追加缓冲区 ***/
/*** 输出 ***/
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    if (y >= E.numrows) {
      if (E.numrows == 0 && y == E.screenrows / 3) {
        char welcome[80];
        int welcomelen = snprintf(welcome, sizeof(welcome),
          "Kilo编辑器——版本 %s", KILO_VERSION);
        if (welcomelen > E.screencols) welcomelen = E.screencols;
        int padding = (E.screencols - welcomelen) / 2;
        if (padding) {
          abAppend(ab, "~"1);
          padding--;
        }
        while (padding--) abAppend(ab, " "1);
        abAppend(ab, welcome, welcomelen);
      } else {
        abAppend(ab, "~"1);
      }
    } else {
      int len = E.row[y].size;
      if (len > E.screencols) len = E.screencols;
      abAppend(ab, E.row[y].chars, len);
    }
    abAppend(ab, "\x1b[K"3);
    if (y < E.screenrows - 1) {
      abAppend(ab, "\r\n"2);
    }
  }
}
void editorRefreshScreen() { … }
/*** 输入 ***/
/*** 初始化 ***/

♎︎ 已编译,但无明显效果
此时代码应该能够编译通过了,但它仍然只能从文件中读取一行内容。我们在editorOpen()函数中添加一个while循环,以便将整个文件读取到E.row中。

kilo.c
步骤65
读取多行内容

/*** 包含文件 ***/
/*** 定义 ***/
/*** 数据 ***/
/*** 终端操作 ***/
/*** 行操作 ***/
/*** 文件输入/输出 ***/
void editorOpen(char *filename) {
  FILE *fp = fopen(filename, "r");
  if (!fp) die("fopen");
  char *line = NULL;
  size_t linecap = 0;
  ssize_t linelen;
  while ((linelen = getline(&line, &linecap, fp)) != -1) {
    while (linelen > 0 && (line[linelen - 1] == '\n' ||
                           line[linelen - 1] == '\r'))
      linelen--;
    editorAppendRow(line, linelen);
  }
  free(line);
  fclose(fp);
}
/*** 追加缓冲区 ***/
/*** 输出 ***/
/*** 输入 ***/
/*** 初始化 ***/

♐︎ 已编译
这个while循环能够正常运行,是因为当getline()函数到达文件末尾且没有更多行可读取时,它会返回-1

现在,比如说当你运行./kilo kilo.c时,你应该会看到屏幕上布满了文本行。

垂直滚动
接下来,我们希望用户能够滚动浏览整个文件,而不只是能看到文件的开头几行。我们在全局编辑器状态中添加一个rowoff(行偏移量)变量,它将记录用户当前滚动到文件的哪一行。

kilo.c
步骤66
行偏移量

/*** 包含文件 ***/
/*** 定义 ***/
/*** 数据 ***/
typedef struct erow { … } erow;
struct editorConfig {
  int cx, cy;
  int rowoff;
  int screenrows;
  int screencols;
  int numrows;
  erow *row;
  struct termios orig_termios;
};
struct editorConfig E;
/*** 终端操作 ***/
/*** 行操作 ***/
/*** 文件输入/输出 ***/
/*** 追加缓冲区 ***/
/*** 输出 ***/
/*** 输入 ***/
/*** 初始化 ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.rowoff = 0;
  E.numrows = 0;
  E.row = NULL;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
}
int main(int argc, char *argv[]) { … }

♎︎ 已编译,但无明显效果
我们将其初始化为0,这意味着默认情况下我们会滚动到文件的顶部。

现在让editorDrawRows()函数根据rowoff的值显示文件中正确的行范围。

kilo.c
步骤67
文件行

/*** 包含文件 ***/
/*** 定义 ***/
/*** 数据 ***/
/*** 终端操作 ***/
/*** 行操作 ***/
/*** 文件输入/输出 ***/
/*** 追加缓冲区 ***/
/*** 输出 ***/
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    int filerow = y + E.rowoff;
    if (filerow >= E.numrows) {
      if (E.numrows == 0 && y == E.screenrows / 3) {
        char welcome[80];
        int welcomelen = snprintf(welcome, sizeof(welcome),
          "Kilo编辑器——版本 %s", KILO_VERSION);
        if (welcomelen > E.screencols) welcomelen = E.screencols;
        int padding = (E.screencols - welcomelen) / 2;
        if (padding) {
          abAppend(ab, "~"1);
          padding--;
        }
        while (padding--) abAppend(ab, " "1);
        abAppend(ab, welcome, welcomelen);
      } else {
        abAppend(ab, "~"1);
      }
    } else {
      int len = E.row[filerow].size;
      if (len > E.screencols) len = E.screencols;
      abAppend(ab, E.row[filerow].chars, len);
    }
    abAppend(ab, "\x1b[K"3);
    if (y < E.screenrows - 1) {
      abAppend(ab, "\r\n"2);
    }
  }
}
void editorRefreshScreen() { … }
/*** 输入 ***/
/*** 初始化 ***/

♎︎ 已编译,但无明显效果
为了得到我们想在每个y位置显示的文件中的行,我们将E.rowoff加到y位置上。所以我们定义一个新的变量filerow来保存这个值,并将其用作E.row的索引。

现在,我们该在哪里设置 E.rowoff 的值呢?我们的策略是检查光标是否移出了可见窗口,如果是,就调整 E.rowoff,让光标刚好处于可见窗口内。我们会把这个逻辑放在一个名为 editorScroll() 的函数里,并且在刷新屏幕之前调用它。

kilo.c
步骤 68
editor-scroll

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() {
  if (E.cy < E.rowoff) {
    E.rowoff = E.cy;
  }
  if (E.cy >= E.rowoff + E.screenrows) {
    E.rowoff = E.cy - E.screenrows + 1;
  }
}
void editorDrawRows(struct abuf *ab) { … }
void editorRefreshScreen() {
  editorScroll();
  struct abuf ab = ABUF_INIT;
  abAppend(&ab, "\x1b[?25l"6);
  abAppend(&ab, "\x1b[H"3);
  editorDrawRows(&ab);
  char buf[32];
  snprintf(buf, sizeof(buf), "\x1b[%d;%dH", E.cy + 1, E.cx + 1);
  abAppend(&ab, buf, strlen(buf));
  abAppend(&ab, "\x1b[?25h"6);
  write(STDOUT_FILENO, ab.b, ab.len);
  abFree(&ab);
}
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

第一个 if 语句检查光标是否位于可见窗口上方,若是则向上滚动到光标所在位置。第二个 if 语句检查光标是否越过了可见窗口底部,这里的算术运算稍复杂些,因为 E.rowoff 指的是屏幕顶部的位置,而要确定屏幕底部的位置就需要用到 E.screenrows

现在,让我们允许光标移动到屏幕底部之外(但不能超过文件末尾)。

kilo.c
步骤 69
enable-vertical-scroll

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) {
  switch (key) {
    case ARROW_LEFT:
      if (E.cx != 0) {
        E.cx--;
      }
      break;
    case ARROW_RIGHT:
      if (E.cx != E.screencols - 1) {
        E.cx++;
      }
      break;
    case ARROW_UP:
      if (E.cy != 0) {
        E.cy--;
      }
      break;
    case ARROW_DOWN:
      if (E.cy < E.numrows) {
        E.cy++;
      }
      break;
  }
}
void editorProcessKeypress() { … }
/*** init ***/

♐︎ 已编译

当你运行 ./kilo kilo.c 时,现在应该能够滚动浏览整个文件了。(如果文件包含制表符,你会发现绘制到屏幕上时,制表符占用的字符无法被正确清除。我们很快会修复这个问题。在此期间,你可以用一个制表符不多的文件进行测试。)

如果你尝试向上滚动,可能会注意到光标定位不正确。这是因为 E.cy 不再表示光标在屏幕上的位置,而是表示光标在文本文件中的位置。要在屏幕上定位光标,现在必须从 E.cy 的值中减去 E.rowoff

kilo.c
步骤 70
fix-cursor-scrolling

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorRefreshScreen() {
  editorScroll();
  struct abuf ab = ABUF_INIT;
  abAppend(&ab, "\x1b[?25l"6);
  abAppend(&ab, "\x1b[H"3);
  editorDrawRows(&ab);
  char buf[32];
  snprintf(buf, sizeof(buf), "\x1b[%d;%dH", (E.cy - E.rowoff) + 1, E.cx + 1);
  abAppend(&ab, buf, strlen(buf));
  abAppend(&ab, "\x1b[?25h"6);
  write(STDOUT_FILENO, ab.b, ab.len);
  abFree(&ab);
}
/*** input ***/
/*** init ***/

♐︎ 已编译

水平滚动
现在,我们来实现水平滚动功能。实现方式和垂直滚动差不多。首先,在全局编辑器状态中添加一个 coloff(列偏移量)变量。

kilo.c
步骤 71
coloff

/*** includes ***/
/*** defines ***/
/*** data ***/
typedef struct erow { … } erow;
struct editorConfig {
  int cx, cy;
  int rowoff;
  int coloff;
  int screenrows;
  int screencols;
  int numrows;
  erow *row;
  struct termios orig_termios;
};
struct editorConfig E;
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.rowoff = 0;
  E.coloff = 0;
  E.numrows = 0;
  E.row = NULL;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
}
int main(int argc, char *argv[]) { … }

♎︎ 已编译,但无明显效果

为了按列偏移量显示每一行,我们会把 E.coloff 作为所显示的每个 erow 的 chars 数组的索引,并且从行的长度中减去偏移量左侧的字符数。

kilo.c
步骤 72
use-coloff

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    int filerow = y + E.rowoff;
    if (filerow >= E.numrows) {
      if (E.numrows == 0 && y == E.screenrows / 3) {
        char welcome[80];
        int welcomelen = snprintf(welcome, sizeof(welcome),
          "Kilo editor -- version %s", KILO_VERSION);
        if (welcomelen > E.screencols) welcomelen = E.screencols;
        int padding = (E.screencols - welcomelen) / 2;
        if (padding) {
          abAppend(ab, "~"1);
          padding--;
        }
        while (padding--) abAppend(ab, " "1);
        abAppend(ab, welcome, welcomelen);
      } else {
        abAppend(ab, "~"1);
      }
    } else {
      int len = E.row[filerow].size - E.coloff;
      if (len < 0) len = 0;
      if (len > E.screencols) len = E.screencols;
      abAppend(ab, &E.row[filerow].chars[E.coloff], len);
    }
    abAppend(ab, "\x1b[K"3);
    if (y < E.screenrows - 1) {
      abAppend(ab, "\r\n"2);
    }
  }
}
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

注意,从行长度中减去 E.coloff 时,len 可能为负数,这意味着用户水平滚动越过了行尾。这种情况下,我们把 len 设为 0,这样该行就不显示任何内容。

现在,更新 editorScroll() 函数以处理水平滚动。

kilo.c
步骤 73
editor-scroll-horizontal

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() {
  if (E.cy < E.rowoff) {
    E.rowoff = E.cy;
  }
  if (E.cy >= E.rowoff + E.screenrows) {
    E.rowoff = E.cy - E.screenrows + 1;
  }
  if (E.cx < E.coloff) {
    E.coloff = E.cx;
  }
  if (E.cx >= E.coloff + E.screencols) {
    E.coloff = E.cx - E.screencols + 1;
  }
}
void editorDrawRows(struct abuf *ab) { … }
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

可以看到,这和垂直滚动的代码非常相似。我们只需把 E.cy 换成 E.cxE.rowoff 换成 E.coloffE.screenrows 换成 E.screencols

现在,让用户能够滚动到屏幕右边缘之外。

kilo.c
步骤 74
enable-horizontal-scroll

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) {
  switch (key) {
    case ARROW_LEFT:
      if (E.cx != 0) {
        E.cx--;
      }
      break;
    case ARROW_RIGHT:
      if (E.cx != E.screencols - 1) {
      E.cx++;
      }
      break;
    case ARROW_UP:
      if (E.cy != 0) {
        E.cy--;
      }
      break;
    case ARROW_DOWN:
      if (E.cy < E.numrows) {
        E.cy++;
      }
      break;
  }
}
void editorProcessKeypress() { … }
/*** init ***/

♐︎ 已编译

现在你应该能确认水平滚动功能正常工作了。

接下来,像处理垂直滚动那样,修复光标的定位问题。

kilo.c
步骤 75
fix-cursor-scrolling-horizontal

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorRefreshScreen() {
  editorScroll();
  struct abuf ab = ABUF_INIT;
  abAppend(&ab, "\x1b[?25l"6);
  abAppend(&ab, "\x1b[H"3);
  editorDrawRows(&ab);
  char buf[32];
  snprintf(buf, sizeof(buf), "\x1b[%d;%dH", (E.cy - E.rowoff) + 1,
                                            (E.cx - E.coloff) + 1);
  abAppend(&ab, buf, strlen(buf));
  abAppend(&ab, "\x1b[?25h"6);
  write(STDOUT_FILENO, ab.b, ab.len);
  abFree(&ab);
}
/*** input ***/
/*** init ***/

♐︎ 已编译

限制向右滚动

现在 E.cx 和 E.cy 都表示光标在文件中的位置,而不是在屏幕上的位置。所以我们接下来几步的目标是限制 E.cx 和 E.cy 的值,使其仅指向文件中的有效位置。否则,用户可能会将光标移动到一行的右侧很远的位置并在那里插入文本,这没有什么意义。(此规则的唯一例外是 E.cx 可以指向一行末尾的下一个字符,以便可以在该行末尾插入字符,并且 E.cy 可以指向文件末尾的下一行,以便可以轻松地在文件末尾添加新行。)

让我们首先不允许用户滚动到当前行的末尾之外。

kilo.c
步骤 76
滚动限制

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) {
  erow *row = (E.cy >= E.numrows) ? NULL : &E.row[E.cy];
  switch (key) {
    case ARROW_LEFT:
      if (E.cx != 0) {
        E.cx--;
      }
      break;
    case ARROW_RIGHT:
      if (row && E.cx < row->size) {
        E.cx++;
      }
      break;
    case ARROW_UP:
      if (E.cy != 0) {
        E.cy--;
      }
      break;
    case ARROW_DOWN:
      if (E.cy < E.numrows) {
        E.cy++;
      }
      break;
  }
}
void editorProcessKeypress() { … }
/*** init ***/

♐︎ 已编译

由于 E.cy 被允许比文件的最后一行大 1,所以我们使用三元运算符来检查光标是否在实际的一行上。如果是,那么 row 变量将指向光标所在的 erow,并且在允许光标向右移动之前,我们将检查 E.cx 是否在该行末尾的左侧。

将光标对齐到行尾
然而,用户仍然能够将光标移动到一行的末尾之外。他们可以通过将光标移动到一行较长的行的末尾,然后将其向下移动到下一行(该行较短)来实现。E.cx 的值不会改变,并且光标将位于它现在所在行的末尾的右侧。

让我们在 editorMoveCursor() 中添加一些代码,如果 E.cx 最终位于它所在行的末尾之外,则纠正 E.cx 的值。

kilo.c
步骤 77
对齐光标

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) {
  erow *row = (E.cy >= E.numrows) ? NULL : &E.row[E.cy];
  switch (key) {
    case ARROW_LEFT:
      if (E.cx != 0) {
        E.cx--;
      }
      break;
    case ARROW_RIGHT:
      if (row && E.cx < row->size) {
        E.cx++;
      }
      break;
    case ARROW_UP:
      if (E.cy != 0) {
        E.cy--;
      }
      break;
    case ARROW_DOWN:
      if (E.cy < E.numrows) {
        E.cy++;
      }
      break;
  }
  row = (E.cy >= E.numrows) ? NULL : &E.row[E.cy];
  int rowlen = row ? row->size : 0;
  if (E.cx > rowlen) {
    E.cx = rowlen;
  }
}
void editorProcessKeypress() { … }
/*** init ***/

♐︎ 已编译

我们必须再次设置 row,因为 E.cy 可能指向与之前不同的行。然后,如果 E.cx 在该行末尾的右侧,我们将 E.cx 设置为该行的末尾。还要注意,我们认为 NULL 行的长度为 0,这在这里符合我们的目的。

在行首向左移动
让我们允许用户在行首按  键移动到上一行的末尾。

kilo.c
步骤 78
向左移动

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) {
  erow *row = (E.cy >= E.numrows) ? NULL : &E.row[E.cy];
  switch (key) {
    case ARROW_LEFT:
      if (E.cx != 0) {
        E.cx--;
      } else if (E.cy > 0) {
        E.cy--;
        E.cx = E.row[E.cy].size;
      }
      break;
    case ARROW_RIGHT:
      if (row && E.cx < row->size) {
        E.cx++;
      }
      break;
    case ARROW_UP:
      if (E.cy != 0) {
        E.cy--;
      }
      break;
    case ARROW_DOWN:
      if (E.cy < E.numrows) {
        E.cy++;
      }
      break;
  }
  row = (E.cy >= E.numrows) ? NULL : &E.row[E.cy];
  int rowlen = row ? row->size : 0;
  if (E.cx > rowlen) {
    E.cx = rowlen;
  }
}
void editorProcessKeypress() { … }
/*** init ***/

♐︎ 已编译

在将他们向上移动一行之前,我们要确保他们不在第一行。

在行尾向右移动
类似地,让我们允许用户在行尾按  键移动到下一行的开头。

kilo.c
步骤 79
向右移动

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) {
  erow *row = (E.cy >= E.numrows) ? NULL : &E.row[E.cy];
  switch (key) {
    case ARROW_LEFT:
      if (E.cx != 0) {
        E.cx--;
      } else if (E.cy > 0) {
        E.cy--;
        E.cx = E.row[E.cy].size;
      }
      break;
    case ARROW_RIGHT:
      if (row && E.cx < row->size) {
        E.cx++;
      } else if (row && E.cx == row->size) {
        E.cy++;
        E.cx = 0;
      }
      break;
    case ARROW_UP:
      if (E.cy != 0) {
        E.cy--;
      }
      break;
    case ARROW_DOWN:
      if (E.cy < E.numrows) {
        E.cy++;
      }
      break;
  }
  row = (E.cy >= E.numrows) ? NULL : &E.row[E.cy];
  int rowlen = row ? row->size : 0;
  if (E.cx > rowlen) {
    E.cx = rowlen;
  }
}
void editorProcessKeypress() { … }
/*** init ***/

♐︎ 已编译

在这里,在向下移动一行之前,我们必须确保他们不在文件的末尾。

渲染制表符
如果你尝试使用 ./kilo Makefile 打开 Makefile,你会注意到 Makefile 第二行的制表符占用大约 8 列的宽度。制表符的长度取决于所使用的终端及其设置。我们想知道每个制表符的长度,并且我们还希望能够控制如何渲染制表符,所以我们将向 erow 结构体添加第二个字符串,名为 render,它将包含为该行文本在屏幕上绘制的实际字符。目前我们仅将 render 用于制表符,但将来它可用于将不可打印的控制字符渲染为 ^ 字符后跟另一个字符,例如 Ctrl-A 字符表示为 ^A(这是在终端中显示控制字符的常见方式)。

你可能还会注意到,当终端显示 Makefile 中的制表符时,它不会擦除该制表符所在屏幕上的任何字符。制表符所做的只是将光标向前移动到下一个制表位,类似于回车或换行。这是我们希望将制表符渲染为多个空格的另一个原因,因为空格会擦除之前在那里的任何字符。

所以,让我们首先将 render 和 rsize(它包含 render 的内容的大小)添加到 erow 结构体中,并在 editorAppendRow() 中初始化它们,editorAppendRow() 是构造和初始化新的 erow 的地方。

kilo.c
步骤 80
渲染

/*** includes ***/
/*** defines ***/
/*** data ***/
typedef struct erow {
  int size;
  int rsize;
  char *chars;
  char *render;
} erow;
struct editorConfig { … };
struct editorConfig E;
/*** terminal ***/
/*** row operations ***/
void editorAppendRow(char *s, size_t len) {
  E.row = realloc(E.row, sizeof(erow) * (E.numrows + 1));
  int at = E.numrows;
  E.row[at].size = len;
  E.row[at].chars = malloc(len + 1);
  memcpy(E.row[at].chars, s, len);
  E.row[at].chars[len] = '\0';
  E.row[at].rsize = 0;
  E.row[at].render = NULL;
  E.numrows++;
}
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

接下来,让我们创建一个 editorUpdateRow() 函数,该函数使用 erow 的 chars 字符串来填充 render 字符串的内容。我们将把每个字符从 chars 复制到 render。我们暂时不考虑如何渲染制表符。

kilo.c
步骤 81
editorUpdateRow 函数

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
void editorUpdateRow(erow *row) {
  free(row->render);
  row->render = malloc(row->size + 1);
  int j;
  int idx = 0;
  for (j = 0; j < row->size; j++) {
    row->render[idx++] = row->chars[j];
  }
  row->render[idx] = '\0';
  row->rsize = idx;
}
void editorAppendRow(char *s, size_t len) {
  E.row = realloc(E.row, sizeof(erow) * (E.numrows + 1));
  int at = E.numrows;
  E.row[at].size = len;
  E.row[at].chars = malloc(len + 1);
  memcpy(E.row[at].chars, s, len);
  E.row[at].chars[len] = '\0';
  E.row[at].rsize = 0;
  E.row[at].render = NULL;
  editorUpdateRow(&E.row[at]);
  E.numrows++;
}
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

在 for 循环之后,idx 包含我们复制到 row->render 中的字符数,所以我们将其赋值给 row->rsize

现在,当我们显示每个 erow 时,在 editorDrawRows() 中用 render 和 rsize 替换 chars 和 size

kilo.c
步骤 82
使用渲染

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    int filerow = y + E.rowoff;
    if (filerow >= E.numrows) {
      if (E.numrows == 0 && y == E.screenrows / 3) {
        char welcome[80];
        int welcomelen = snprintf(welcome, sizeof(welcome),
          "Kilo editor -- version %s", KILO_VERSION);
        if (welcomelen > E.screencols) welcomelen = E.screencols;
        int padding = (E.screencols - welcomelen) / 2;
        if (padding) {
          abAppend(ab, "~"1);
          padding--;
        }
        while (padding--) abAppend(ab, " "1);
        abAppend(ab, welcome, welcomelen);
      } else {
        abAppend(ab, "~"1);
      }
    } else {
      int len = E.row[filerow].rsize - E.coloff;
      if (len < 0) len = 0;
      if (len > E.screencols) len = E.screencols;
      abAppend(ab, &E.row[filerow].render[E.coloff], len);
    }
    abAppend(ab, "\x1b[K"3);
    if (y < E.screenrows - 1) {
      abAppend(ab, "\r\n"2);
    }
  }
}
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

现在文本查看器正在显示 render 中的字符。让我们在 editorUpdateRow() 中添加代码,将制表符渲染为多个空格字符。

kilo.c
步骤 83
制表符

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
void editorUpdateRow(erow *row) {
  int tabs = 0;
  int j;
  for (j = 0; j < row->size; j++)
    if (row->chars[j] == '\t') tabs++;
  free(row->render);
  row->render = malloc(row->size + tabs*7 + 1);
  int idx = 0;
  for (j = 0; j < row->size; j++) {
    if (row->chars[j] == '\t') {
      row->render[idx++] = ' ';
      while (idx % 8 != 0) row->render[idx++] = ' ';
    } else {
      row->render[idx++] = row->chars[j];
    }
  }
  row->render[idx] = '\0';
  row->rsize = idx;
}
void editorAppendRow(char *s, size_t len) { … }
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/

♐︎ 已编译

首先,我们必须遍历该行的 chars 并计算制表符的数量,以便知道为 render 分配多少内存。每个制表符所需的最大字符数为 8。row->size 已经为每个制表符计数为 1,所以我们将制表符的数量乘以 7 并将其加到 row->size 上,以得到渲染该行所需的最大内存量。

分配内存后,我们修改 for 循环以检查当前字符是否为制表符。如果是,我们追加一个空格(因为每个制表符必须至少将光标向前移动一列),然后追加空格,直到我们到达制表位,即能被 8 整除的列。
在这一点上,我们或许应该把制表位的长度设为一个常量。

kilo.c
步骤 84
制表位

/*** includes ***/
/*** defines ***/
#define KILO_VERSION "0.0.1"
#define KILO_TAB_STOP 8
#define CTRL_KEY(k) ((k) & 0x1f)
enum editorKey { … };
/*** data ***/
/*** terminal ***/
/*** row operations ***/
void editorUpdateRow(erow *row) {
  int tabs = 0;
  int j;
  for (j = 0; j < row->size; j++)
    if (row->chars[j] == '\t') tabs++;
  free(row->render);
  row->render = malloc(row->size + tabs*(KILO_TAB_STOP - 1) + 1);
  int idx = 0;
  for (j = 0; j < row->size; j++) {
    if (row->chars[j] == '\t') {
      row->render[idx++] = ' ';
      while (idx % KILO_TAB_STOP != 0) row->render[idx++] = ' ';
    } else {
      row->render[idx++] = row->chars[j];
    }
  }
  row->render[idx] = '\0';
  row->rsize = idx;
}
void editorAppendRow(char *s, size_t len) { … }
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

这使得代码更清晰,同时也让制表位的长度变得可配置。

制表符与光标
目前,光标与制表符的交互效果不太好。当我们在屏幕上定位光标时,我们仍然假定每个字符在屏幕上只占一列。为了解决这个问题,我们引入一个新的水平坐标变量 E.rxE.cx 是 erow 结构体中 chars 字段的索引,而 E.rx 变量将是 render 字段的索引。如果当前行没有制表符,那么 E.rx 将与 E.cx 相同。如果有制表符,那么 E.rx 将比 E.cx 大,大的值就是这些制表符在渲染时所占用的额外空格数。

首先,将 rx 添加到全局状态结构体中,并将其初始化为 0。

kilo.c
步骤 85
rx 变量

/*** includes ***/
/*** defines ***/
/*** data ***/
typedef struct erow { … } erow;
struct editorConfig {
  int cx, cy;
  int rx;
  int rowoff;
  int coloff;
  int screenrows;
  int screencols;
  int numrows;
  erow *row;
  struct termios orig_termios;
};
struct editorConfig E;
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.rx = 0;
  E.rowoff = 0;
  E.coloff = 0;
  E.numrows = 0;
  E.row = NULL;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
}
int main(int argc, char *argv[]) { … }

♎︎ 已编译,但无明显效果

我们将在 editorScroll() 的开头设置 E.rx 的值。目前,我们先将它设为与 E.cx 相同。然后,在 editorScroll() 中,我们将所有出现的 E.cx 替换为 E.rx,因为滚动时应该考虑实际渲染到屏幕上的字符,以及光标的渲染位置。

kilo.c
步骤 86
rx 与滚动

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() {
  E.rx = E.cx;
  if (E.cy < E.rowoff) {
    E.rowoff = E.cy;
  }
  if (E.cy >= E.rowoff + E.screenrows) {
    E.rowoff = E.cy - E.screenrows + 1;
  }
  if (E.rx < E.coloff) {
    E.coloff = E.rx;
  }
  if (E.rx >= E.coloff + E.screencols) {
    E.coloff = E.rx - E.screencols + 1;
  }
}
void editorDrawRows(struct abuf *ab) { … }
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

现在,在 editorRefreshScreen() 中设置光标位置的地方,将 E.cx 改为 E.rx

kilo.c
步骤 87
使用 rx

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorRefreshScreen() {
  editorScroll();
  struct abuf ab = ABUF_INIT;
  abAppend(&ab, "\x1b[?25l"6);
  abAppend(&ab, "\x1b[H"3);
  editorDrawRows(&ab);
  char buf[32];
  snprintf(buf, sizeof(buf), "\x1b[%d;%dH", (E.cy - E.rowoff) + 1,
                                            (E.rx - E.coloff) + 1);
  abAppend(&ab, buf, strlen(buf));
  abAppend(&ab, "\x1b[?25h"6);
  write(STDOUT_FILENO, ab.b, ab.len);
  abFree(&ab);
}
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

剩下要做的就是在 editorScroll() 中正确计算 E.rx 的值。我们创建一个 editorRowCxToRx() 函数,它将 chars 的索引转换为 render 的索引。我们需要遍历 cx 左侧的所有字符,并计算每个制表符占用多少个空格。

kilo.c
步骤 88
cx 转换为 rx

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
int editorRowCxToRx(erow *row, int cx) {
  int rx = 0;
  int j;
  for (j = 0; j < cx; j++) {
    if (row->chars[j] == '\t')
      rx += (KILO_TAB_STOP - 1) - (rx % KILO_TAB_STOP);
    rx++;
  }
  return rx;
}
void editorUpdateRow(erow *row) { … }
void editorAppendRow(char *s, size_t len) { … }
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/

♎︎ 已编译,但无明显效果

对于每个字符,如果它是制表符,我们使用 rx % KILO_TAB_STOP 来确定我们距离上一个制表位右侧有多少列,然后用 KILO_TAB_STOP - 1 减去这个值,以确定我们距离下一个制表位左侧有多少列。我们将这个数值加到 rx 上,使 rx 正好位于下一个制表位的左侧,然后无条件的 rx++ 语句会让我们正好处于下一个制表位上。注意,即使我们当前就在制表位上,这个方法也能正常工作。

让我们在 editorScroll() 的开头调用 editorRowCxToRx(),最终将 E.rx 设置为其正确的值。

kilo.c
步骤 89
设置 rx

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() {
  E.rx = 0;
  if (E.cy < E.numrows) {
    E.rx = editorRowCxToRx(&E.row[E.cy], E.cx);
  }
  if (E.cy < E.rowoff) {
    E.rowoff = E.cy;
  }
  if (E.cy >= E.rowoff + E.screenrows) {
    E.rowoff = E.cy - E.screenrows + 1;
  }
  if (E.rx < E.coloff) {
    E.coloff = E.rx;
  }
  if (E.rx >= E.coloff + E.screencols) {
    E.coloff = E.rx - E.screencols + 1;
  }
}
void editorDrawRows(struct abuf *ab) { … }
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/

♐︎ 已编译

现在你应该能够确认,光标在包含制表符的行内能够正确移动了。

使用 Page Up 和 Page Down 键进行滚动

既然我们已经实现了滚动功能,那就让 Page Up(向上翻页)和 Page Down(向下翻页)键能实现整页的向上或向下滚动吧。

kilo.c
步骤 90
向上翻页和向下翻页

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) { … }
void editorProcessKeypress() {
  int c = editorReadKey();
  switch (c) {
    case CTRL_KEY('q'):
      write(STDOUT_FILENO, "\x1b[2J"4);
      write(STDOUT_FILENO, "\x1b[H"3);
      exit(0);
      break;
    case HOME_KEY:
      E.cx = 0;
      break;
    case END_KEY:
      E.cx = E.screencols - 1;
      break;
    case PAGE_UP:
    case PAGE_DOWN:
      {
        if (c == PAGE_UP) {
          E.cy = E.rowoff;
        } else if (c == PAGE_DOWN) {
          E.cy = E.rowoff + E.screenrows - 1;
          if (E.cy > E.numrows) E.cy = E.numrows;
        }
        int times = E.screenrows;
        while (times--)
          editorMoveCursor(c == PAGE_UP ? ARROW_UP : ARROW_DOWN);
      }
      break;
    case ARROW_UP:
    case ARROW_DOWN:
    case ARROW_LEFT:
    case ARROW_RIGHT:
      editorMoveCursor(c);
      break;
  }
}
/*** init ***/

♐︎ 已编译

为了实现整页的向上或向下滚动,我们将光标定位到屏幕的顶部或底部,然后模拟一整屏的向上箭头(↑)或向下箭头(↓)按键操作。委托给 editorMoveCursor() 函数来处理在移动光标时所需的所有边界检查和光标修正操作。

使用 End 键移动到行尾
现在,让我们让 End 键将光标移动到当前行的末尾。(Home 键已经能将光标移动到行首了,因为我们让 E.cx 相对于文件而不是相对于屏幕。)

kilo.c
步骤 91
End 键功能

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
void editorMoveCursor(int key) { … }
void editorProcessKeypress() {
  int c = editorReadKey();
  switch (c) {
    case CTRL_KEY('q'):
      write(STDOUT_FILENO, "\x1b[2J"4);
      write(STDOUT_FILENO, "\x1b[H"3);
      exit(0);
      break;
    case HOME_KEY:
      E.cx = 0;
      break;
    case END_KEY:
      if (E.cy < E.numrows)
        E.cx = E.row[E.cy].size;
      break;
    case PAGE_UP:
    case PAGE_DOWN:
      {
        if (c == PAGE_UP) {
          E.cy = E.rowoff;
        } else if (c == PAGE_DOWN) {
          E.cy = E.rowoff + E.screenrows - 1;
          if (E.cy > E.numrows) E.cy = E.numrows;
        }
        int times = E.screenrows;
        while (times--)
          editorMoveCursor(c == PAGE_UP ? ARROW_UP : ARROW_DOWN);
      }
      break;
    case ARROW_UP:
    case ARROW_DOWN:
    case ARROW_LEFT:
    case ARROW_RIGHT:
      editorMoveCursor(c);
      break;
  }
}
/*** init ***/

♐︎ 已编译

End 键会将光标移动到当前行的末尾。如果没有当前行,那么 E.cx 必须为 0 且应保持为 0,所以无需进行任何操作。

状态栏
在最终实现文本编辑功能之前,我们要添加的最后一项内容是状态栏。状态栏将显示一些有用的信息,比如文件名、文件中的行数以及你当前所在的行。稍后,我们会添加一个标记,用于提示文件自上次保存后是否被修改过,并且在实现语法高亮时,我们还会显示文件类型。

首先,我们要在屏幕底部留出一行的空间来显示状态栏。

kilo.c
步骤 92
为状态栏腾出空间

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) {
  int y;
  for (y = 0; y < E.screenrows; y++) {
    int filerow = y + E.rowoff;
    if (filerow >= E.numrows) {
      if (E.numrows == 0 && y == E.screenrows / 3) {
        char welcome[80];
        int welcomelen = snprintf(welcome, sizeof(welcome),
          "Kilo editor -- version %s", KILO_VERSION);
        if (welcomelen > E.screencols) welcomelen = E.screencols;
        int padding = (E.screencols - welcomelen) / 2;
        if (padding) {
          abAppend(ab, "~"1);
          padding--;
        }
        while (padding--) abAppend(ab, " "1);
        abAppend(ab, welcome, welcomelen);
      } else {
        abAppend(ab, "~"1);
      }
    } else {
      int len = E.row[filerow].rsize - E.coloff;
      if (len < 0) len = 0;
      if (len > E.screencols) len = E.screencols;
      abAppend(ab, &E.row[filerow].render[E.coloff], len);
    }
    abAppend(ab, "\x1b[K"3);
    if (y < E.screenrows - 1) {
    abAppend(ab, "\r\n"2);
  }
  }
}
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.rx = 0;
  E.rowoff = 0;
  E.coloff = 0;
  E.numrows = 0;
  E.row = NULL;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
  E.screenrows -= 1;
}
int main(int argc, char *argv[]) { … }

♐︎ 已编译

我们将 E.screenrows 减 1,这样 editorDrawRows() 就不会尝试在屏幕底部绘制一行文本了。并且我们让 editorDrawRows() 在绘制完最后一行后打印一个换行符,因为现在状态栏是屏幕上绘制的最后一行。

请注意,通过这两处修改,我们的文本查看器仍然能正常工作,包括滚动和光标移动功能,而且状态栏所在的最后一行不会受到其他显示代码的影响。

为了让状态栏更加醒目,我们将使用反色来显示它:白底黑字。转义序列 \x1b[7m 用于切换到反色模式,\x1b[m 用于切换回正常格式。我们来绘制一个由反色空格字符组成的空白白色状态栏。

kilo.c
步骤 93
空白状态栏

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorDrawStatusBar(struct abuf *ab) {
  abAppend(ab, "\x1b[7m"4);
  int len = 0;
  while (len < E.screencols) {
    abAppend(ab, " "1);
    len++;
  }
  abAppend(ab, "\x1b[m"3);
}
void editorRefreshScreen() {
  editorScroll();
  struct abuf ab = ABUF_INIT;
  abAppend(&ab, "\x1b[?25l"6);
  abAppend(&ab, "\x1b[H"3);
  editorDrawRows(&ab);
  editorDrawStatusBar(&ab);
  char buf[32];
  snprintf(buf, sizeof(buf), "\x1b[%d;%dH", (E.cy - E.rowoff) + 1,
                                            (E.rx - E.coloff) + 1);
  abAppend(&ab, buf, strlen(buf));
  abAppend(&ab, "\x1b[?25h"6);
  write(STDOUT_FILENO, ab.b, ab.len);
  abFree(&ab);
}
/*** input ***/
/*** init ***/

♐︎ 已编译

m 命令(选择图形渲染方式)会使在它之后打印的文本具有各种可能的属性,包括加粗(1)、下划线(4)、闪烁(5)和反色(7)。例如,你可以使用命令 \x1b[1;4;5;7m 来指定所有这些属性。参数 0 会清除所有属性,并且这是默认参数,所以我们使用 \x1b[m 来恢复正常的文本格式。

由于我们想在状态栏中显示文件名,那就向全局编辑器状态中添加一个 filename 字符串,并在打开文件时将文件名的副本保存到那里。

kilo.c
步骤 94
文件名

/*** includes ***/
/*** defines ***/
/*** data ***/
typedef struct erow { … } erow;
struct editorConfig {
  int cx, cy;
  int rx;
  int rowoff;
  int coloff;
  int screenrows;
  int screencols;
  int numrows;
  erow *row;
  char *filename;
  struct termios orig_termios;
};
struct editorConfig E;
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
void editorOpen(char *filename) {
  free(E.filename);
  E.filename = strdup(filename);
  FILE *fp = fopen(filename, "r");
  if (!fp) die("fopen");
  char *line = NULL;
  size_t linecap = 0;
  ssize_t linelen;
  while ((linelen = getline(&line, &linecap, fp)) != -1) {
    while (linelen > 0 && (line[linelen - 1] == '\n' ||
                           line[linelen - 1] == '\r'))
      linelen--;
    editorAppendRow(line, linelen);
  }
  free(line);
  fclose(fp);
}
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.rx = 0;
  E.rowoff = 0;
  E.coloff = 0;
  E.numrows = 0;
  E.row = NULL;
  E.filename = NULL;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
  E.screenrows -= 1;
}
int main(int argc, char *argv[]) { … }

♎︎ 已编译,但无明显效果

strdup() 函数来自 <string.h> 头文件。它会对给定的字符串进行复制,分配所需的内存,并假定你会使用 free() 函数释放该内存。

我们将 E.filename 初始化为空指针,如果没有打开文件(当程序在没有参数的情况下运行时就会这样),它将保持为空指针。

现在我们准备在状态栏中显示一些信息了。我们将显示文件名的前 20 个字符,然后显示文件中的行数。如果没有文件名,我们将显示 [No Name] 来代替。

kilo.c
步骤 95
状态栏左侧内容

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorDrawStatusBar(struct abuf *ab) {
  abAppend(ab, "\x1b[7m"4);
  char status[80];
  int len = snprintf(status, sizeof(status), "%.20s - %d lines",
    E.filename ? E.filename : "[No Name]", E.numrows);
  if (len > E.screencols) len = E.screencols;
  abAppend(ab, status, len);
  while (len < E.screencols) {
    abAppend(ab, " "1);
    len++;
  }
  abAppend(ab, "\x1b[m"3);
}
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/

♐︎ 已编译

我们确保在状态字符串超出窗口宽度的情况下将其截断。请注意,我们仍然使用代码在状态栏中填充空格直到屏幕末尾,这样整个状态栏就会有白色背景。

现在让我们显示当前行号,并将其对齐到屏幕的右边缘。

kilo.c
步骤 96
状态栏右侧内容

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorDrawStatusBar(struct abuf *ab) {
  abAppend(ab, "\x1b[7m"4);
  char status[80], rstatus[80];
  int len = snprintf(status, sizeof(status), "%.20s - %d lines",
    E.filename ? E.filename : "[No Name]", E.numrows);
  int rlen = snprintf(rstatus, sizeof(rstatus), "%d/%d",
    E.cy + 1, E.numrows);
  if (len > E.screencols) len = E.screencols;
  abAppend(ab, status, len);
  while (len < E.screencols) {
    if (E.screencols - len == rlen) {
      abAppend(ab, rstatus, rlen);
      break;
    } else {
      abAppend(ab, " "1);
      len++;
    }
  }
  abAppend(ab, "\x1b[m"3);
}
void editorRefreshScreen() { … }
/*** input ***/
/*** init ***/

♐︎ 已编译

当前行存储在 E.cy 中,由于 E.cy 是从 0 开始计数的,所以我们要加 1 来显示实际的行号。在打印完第一个状态字符串后,我们要不断打印空格,直到达到这样一个位置:如果此时打印第二个状态字符串,它刚好能在屏幕右边缘结束。当 E.screencols - len 等于第二个状态字符串的长度时,就会出现这种情况。此时我们打印状态字符串并跳出循环,因为整个状态栏现在已经打印完成了。

状态消息

我们将在状态栏下方再添加一行。这一行将用于向用户显示消息,例如在进行搜索时向用户提示输入信息。我们会将当前消息存储在一个名为 statusmsg 的字符串中,并将其放在全局编辑器状态里。我们还会存储该消息的时间戳,这样在消息显示几秒钟后我们就可以将其清除。

kilo.c
步骤 97
状态消息

/*** includes ***/
#define _DEFAULT_SOURCE
#define _BSD_SOURCE
#define _GNU_SOURCE
#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
/*** defines ***/
/*** data ***/
typedef struct erow { … } erow;
struct editorConfig {
  int cx, cy;
  int rx;
  int rowoff;
  int coloff;
  int screenrows;
  int screencols;
  int numrows;
  erow *row;
  char *filename;
  char statusmsg[80];
  time_t statusmsg_time;
  struct termios orig_termios;
};
struct editorConfig E;
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
/*** input ***/
/*** init ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.rx = 0;
  E.rowoff = 0;
  E.coloff = 0;
  E.numrows = 0;
  E.row = NULL;
  E.filename = NULL;
  E.statusmsg[0] = '\0';
  E.statusmsg_time = 0;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
  E.screenrows -= 1;
}
int main(int argc, char *argv[]) { … }

♎︎ 已编译,但无明显效果

time_t 类型来自 <time.h> 头文件。

我们将 E.statusmsg 初始化为一个空字符串,所以默认情况下不会显示任何消息。当我们设置一个状态消息时,E.statusmsg_time 将包含该消息的时间戳。

让我们定义一个 editorSetStatusMessage() 函数。这个函数将接受一个格式字符串和可变数量的参数,就像 printf() 系列函数一样。

kilo.c
步骤 98
设置状态消息

/*** includes ***/
#define _DEFAULT_SOURCE
#define _BSD_SOURCE
#define _GNU_SOURCE
#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorDrawStatusBar(struct abuf *ab) { … }
void editorRefreshScreen() { … }
void editorSetStatusMessage(const char *fmt, ...) {
  va_list ap;
  va_start(ap, fmt);
  vsnprintf(E.statusmsg, sizeof(E.statusmsg), fmt, ap);
  va_end(ap);
  E.statusmsg_time = time(NULL);
}
/*** input ***/
/*** init ***/
void initEditor() { … }
int main(int argc, char *argv[]) {
  enableRawMode();
  initEditor();
  if (argc >= 2) {
    editorOpen(argv[1]);
  }
  editorSetStatusMessage("HELP: Ctrl-Q = quit");
  while (1) {
    editorRefreshScreen();
    editorProcessKeypress();
  }
  return 0;
}

♎︎ 已编译,但无明显效果

va_listva_start() 和 va_end() 来自 <stdarg.h> 头文件。vsnprintf() 来自 <stdio.h> 头文件。time() 来自 <time.h> 头文件。

在 main() 函数中,我们将初始状态消息设置为一条帮助消息,其中包含了我们文本编辑器所使用的按键绑定(目前只有 Ctrl-Q 用于退出)。

vsnprintf() 函数帮助我们实现了自己的类似 printf() 风格的函数。我们将生成的字符串存储在 E.statusmsg 中,并将 E.statusmsg_time 设置为当前时间,这可以通过将 NULL 传递给 time() 函数来获取。(它返回自 1970 年 1 月 1 日午夜以来经过的秒数,以整数形式表示。)

... 参数使得 editorSetStatusMessage() 成为一个可变参数函数,这意味着它可以接受任意数量的参数。C 语言处理这些参数的方式是,你需要对一个 va_list 类型的值调用 va_start() 和 va_end()... 之前的最后一个参数(在这种情况下是 fmt)必须传递给 va_start(),以便知道下一个参数的地址。然后,在 va_start() 和 va_end() 调用之间,你可以调用 va_arg() 并传递下一个参数的类型(通常从给定的格式字符串中获取),它将返回该参数的值。在这种情况下,我们将 fmt 和 ap 传递给 vsnprintf(),它会负责读取格式字符串并调用 va_arg() 来获取每个参数。

现在我们有了要显示的状态消息,让我们在状态栏下方再腾出一行的空间来显示这条消息。

kilo.c
步骤 99
为消息栏腾出空间

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorDrawStatusBar(struct abuf *ab) {
  abAppend(ab, "\x1b[7m"4);
  char status[80], rstatus[80];
  int len = snprintf(status, sizeof(status), "%.20s - %d lines",
    E.filename ? E.filename : "[No Name]", E.numrows);
  int rlen = snprintf(rstatus, sizeof(rstatus), "%d/%d",
    E.cy + 1, E.numrows);
  if (len > E.screencols) len = E.screencols;
  abAppend(ab, status, len);
  while (len < E.screencols) {
    if (E.screencols - len == rlen) {
      abAppend(ab, rstatus, rlen);
      break;
    } else {
      abAppend(ab, " "1);
      len++;
    }
  }
  abAppend(ab, "\x1b[m"3);
  abAppend(ab, "\r\n"2);
}
void editorRefreshScreen() { … }
void editorSetStatusMessage(const char *fmt, ...) { … }
/*** input ***/
/*** init ***/
void initEditor() {
  E.cx = 0;
  E.cy = 0;
  E.rx = 0;
  E.rowoff = 0;
  E.coloff = 0;
  E.numrows = 0;
  E.row = NULL;
  E.filename = NULL;
  E.statusmsg[0] = '\0';
  E.statusmsg_time = 0;
  if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
  E.screenrows -= 2;
}
int main(int argc, char *argv[]) { … }

♐︎ 已编译

我们再次将 E.screenrows 减 1,并在第一个状态栏之后打印一个换行符。现在我们又有了一个空白的最后一行。

让我们在一个新的 editorDrawMessageBar() 函数中绘制消息栏。

kilo.c
步骤 100
绘制消息栏

/*** includes ***/
/*** defines ***/
/*** data ***/
/*** terminal ***/
/*** row operations ***/
/*** file i/o ***/
/*** append buffer ***/
/*** output ***/
void editorScroll() { … }
void editorDrawRows(struct abuf *ab) { … }
void editorDrawStatusBar(struct abuf *ab) { … }
void editorDrawMessageBar(struct abuf *ab) {
  abAppend(ab, "\x1b[K"3);
  int msglen = strlen(E.statusmsg);
  if (msglen > E.screencols) msglen = E.screencols;
  if (msglen && time(NULL) - E.statusmsg_time < 5)
    abAppend(ab, E.statusmsg, msglen);
}
void editorRefreshScreen() {
  editorScroll();
  struct abuf ab = ABUF_INIT;
  abAppend(&ab, "\x1b[?25l"6);
  abAppend(&ab, "\x1b[H"3);
  editorDrawRows(&ab);
  editorDrawStatusBar(&ab);
  editorDrawMessageBar(&ab);
  char buf[32];
  snprintf(buf, sizeof(buf), "\x1b[%d;%dH", (E.cy - E.rowoff) + 1,
                                            (E.rx - E.coloff) + 1);
  abAppend(&ab, buf, strlen(buf));
  abAppend(&ab, "\x1b[?25h"6);
  write(STDOUT_FILENO, ab.b, ab.len);
  abFree(&ab);
}
void editorSetStatusMessage(const char *fmt, ...) { … }
/*** input ***/
/*** init ***/

♐︎ 已编译

首先,我们使用 \x1b[K 转义序列清除消息栏。然后,我们确保消息的长度适合屏幕宽度,并且只有在消息显示时间小于 5 秒的情况下才显示该消息。

现在当你启动程序时,你应该会在底部看到帮助消息。在 5 秒钟后你按下一个键时,它就会消失。请记住,我们只在每次按键后刷新屏幕。

在下一章中,我们将把我们的文本查看器转变为一个文本编辑器,允许用户插入和删除字符,并将他们的更改保存到磁盘上。

参考文献

《A text viewer》

阅读剩余
THE END