# c-kilo-text-editor **Repository Path**: mancuojie/c-kilo-text-editor ## Basic Information - **Project Name**: c-kilo-text-editor - **Description**: 一个单文件文本编辑器 - **Primary Language**: C - **License**: MIT - **Default Branch**: master - **Homepage**: https://gitee.com/mancuojie/c-kilo-text-editor - **GVP Project**: No ## Statistics - **Stars**: 6 - **Forks**: 1 - **Created**: 2022-01-01 - **Last Updated**: 2024-08-16 ## Categories & Tags **Categories**: Uncategorized **Tags**: 文本编辑器, C项目实战, C语言基础, 单文件, 无依赖库 ## README # Kilo kilo是一个**单文件**文本编辑器,没有任何依赖库,包含大约1000行C代码,它实现了编辑器的基本功能,以及语法高亮和搜索,大概可以称为丐版vi😂 只需要你有一定C语言基础,就可以轻松上手本项目。 其核心文件只有两个: ``` . ├── Makefile └── kilo.c ``` 关于如何使用该项目: ```shell git clone https://gitee.com/mancuojie/c-kilo-text-editor.git cd c-kilo-text-editor/ make ./kilo ``` 本项目是对开源项目[antirez/kilo](https://github.com/antirez/kilo)以及英文教程[Build Your Own Text Editor](https://viewsourcecode.org/snaptoken/kilo/index.html)的拙劣模仿,我添加并改动了部分内容,同时也写了一个step by step的教程,姑且可以看作简化后的中文翻译。 你可以从这个项目中学到什么: 1. 从无到有,一步步搭建起一个完整的项目 2. 重构,抽象,debug经验 3. 一点点Linux以及终端知识 4. 可以通过git保存每一步的结果,学会版本控制 5. ... # 1. Setup ## 环境准备 - 此项目只需要C编译器以及make(cc即gcc): ```shell cc -v make -v # Ubuntu安装方法 sudo apt-get install gcc make ``` - 万物初始: ```c #include int main() { printf("Hello World!\n"); return 0; } ``` - 编译运行: ```shell cc kilo.c -o kilo ./kilo ``` `-o`代表output,同时指定kilo为可执行文件的名字,`./kilo`运行该程序。 ## make 新建Makefile,用make编译: ```makefile kilo: kilo.c $(CC) kilo.c -o kilo -Wall -Wextra -pedantic -std=c99 ``` 第一行的kilo是我们要生成的目标文件,kilo.c是构建它所依赖的文件。 第二行指定要运行的命令,以便从kilo.c中构建kilo,你需要确保使用的是tab键而不是空格缩进(Makefile要求必须使用tab键缩进)。 - `$(CC)`:默认编译器。 - `-Wall`:“all Warnings”,发出警告。 - `-Wextra -pedantic`:打开更多警告,对debug会有帮助。 - `-std=c99`:指定C语言标准。 # 2. Entering raw mode ## 启示 使用`read()`来读取输入: ```c #include #include int main() { char c; // 键入一行包含q的文本再按Enter即可退出程序,当然Ctrl-C也可以 while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q'); return 0; } ``` 运行程序,敲敲键盘实验一下,你会发现终端的两个特点: 1. **echoing**即回显,输入就会显示。 1. **canonical mode**也叫**cooked mode**,与我们想要进入的**raw mode**相对。 在这个规范模式下,只有按下`Enter`键,程序才会接收到输入的字符,也就是按行输入。 ## 关闭echoing和canonial mode 我们的编辑器显然不需要这种自带的特性,通过``中的 get 和 set 方法来修改终端属性,同时保留其副本,使终端在程序退出时恢复原样: ```c #include #include #include #include struct termios orig_termios; void disableRawMode() { tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios); } void enableRawMode() { tcgetattr(STDIN_FILENO, &orig_termios); atexit(disableRawMode); struct termios raw = orig_termios; raw.c_lflag &= ~(ECHO | ICANON); tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); } int main() { enableRawMode(); ... } ``` - `atexit()`确保程序无论怎么结束都调用`disableRawMode()`。 - `TCSAFLUSH`指定何时应用更改,这种情况下会丢弃所有尚未读取的输入。 - `c_lflag`指“local flags”,同时还有输入输入iflag、输出oflag、控制cflag字段,我们必须修改它们来启用**raw mode**。 通过取反+按位与`&= ~`来翻转标志位,举个例子: $$ 二进制数:0000 1000 \\ 取反后: 1111 0111 \\ 再按位与它本身得到:0000 0000 \\ 可以看到只翻转了其中一位 $$ ## how to work? 首先我们来了解一下raw mode下是怎么输入的: ```c ... #include ... int main() { enableRawMode(); char c; while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q') { if (iscntrl(c)) { // iscontrol,顾名思义,判断是否为控制字符 printf("%d\n", c); } else { printf("%d ('%c')\n", c, c); // 非控制字符输出ASCII码及字符 } } return 0; } ``` 按键查看输出可以发现: - 方向键,`PageUp`,`PageDown`,`End`和`Home`都是三字节或者四字节,且都是以`27 [` 开头加上一两个其他字符,这被称为转义序列。所有的转义序列都是以27开头,同时27也是`Esc`键的值,所以开头又可以写作`[`或十六进制`\x1b[`。 - `Backspace`是127,`Enter`是10也写作`\n`。 - `Ctrl-A`是1,`Ctrl-B`是2,所以`Ctrl-C`是3?不,它是**终止程序**,但是只要是有效的Ctrl组合A-Z就是1-26。 - `Ctrl-S`会使程序停止输出,通过`Ctrl-Q`恢复。 - `Ctrl-Z`或者`Ctrl-Y`会使程序暂停,通过`fg`命令恢复。 ![img](https://gitee.com/mancuojie/typo/raw/master/202201061544663.png) ## 关闭及修复按键功能 1. 关闭`Ctrl-C`,`Ctrl-Z`以及`Ctrl-V` 2. 关闭`Ctrl-S`和`Ctrl-Q` 3. 修复`Ctrl-M` 4. 关闭所有输出处理 5. 关闭其他远古时代遗留的标志位 ```c raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); raw.c_oflag &= ~(OPOST); raw.c_cflag |= (CS8); raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); ``` 在修复之前`Ctrl-M`按下会显示10,而且`Enter`键也是10,这是因为终端特性会将任何13即`\r`转换成10即`\n`。 而且由于打字机时代的遗留,终端会将所有换行符`\n`转换成回车+换行`\r\n`: - `\r`是将光标移到当前行的开头 - `\n`是将光标向下移动一行 我们关闭所有输出处理后,所有也代表关闭了这个遗留特性,输出会变成: ![image.png](https://gitee.com/mancuojie/typo/raw/master/202201061537483.png) 所以我们需要同时在`printf`里手动添加`\r`: ```c int main() { enableRawMode(); char c; while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q') { if (iscntrl(c)) { printf("%d\r\n", c); } else { printf("%d ('%c')\r\n", c, c); } } return 0; } ``` ## 添加超时处理 运行程序时,我们会发现程序一直阻塞在`read()`处等待键盘输入,如果在等待的过程中我们想要做些别的事情,就要设置timeout超时处理。 这样设置如果一定时间没有读到输入,就可以直接返回做别的事情(时间间隔很小不用担心输入会被影响): ```c ... // timeout raw.c_cc[VMIN] = 0; raw.c_cc[VTIME] = 1; tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); } int main() { enableRawMode(); while(1) { char c = '\0'; read(STDIN_FILENO, &c, 1); if (iscntrl(c)) { printf("%d\r\n", c); } else { printf("%d ('%c')\r\n", c, c); } if (c == 'q') break; } return 0; } ``` - `VMIN`代表最小可读的字节数,设置为0读取任何可读的字节。 - `VTIME`表示最长等待时间,设置为1表示100毫秒,如果超过时间就会返回0,进入下一个循环。 运行过程中可以看到程序一直返回0,但当按下按键时,就会显示按键的编码: ![image.png](https://gitee.com/mancuojie/typo/raw/master/202201061547464.png) ## 添加错误处理 遇到错误时打印错误信息,然后直接退出程序,足够简单但好用: ```c void die(const char *msg) { perror(msg); exit(1); } ``` `perror()`查看全局errno变量并打印错误信息,在此之前还会打印传递给它的字符串,旨在找到是代码的哪一部分导致的错误,我们把可能出现错误的地方都加上此函数: ```c #include #include #include #include #include #include struct termios orig_termios; void die(const char *msg) { perror(msg); exit(1); } void disableRawMode() { if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios) == -1) { die("set"); } } void enableRawMode() { if (tcgetattr(STDIN_FILENO, &orig_termios) == -1) { die("get"); } atexit(disableRawMode); struct termios raw = orig_termios; // fix flags raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); raw.c_cflag |= (CS8); raw.c_oflag &= ~(OPOST); raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); // timeout raw.c_cc[VMIN] = 0; raw.c_cc[VTIME] = 1; if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) { die("set"); } } int main() { enableRawMode(); while(1) { char c = '\0'; if (read(STDIN_FILENO, &c, 1) == -1 && errno != EAGAIN) { die("read"); } if (iscntrl(c)) { printf("%d\r\n", c); } else { printf("%d ('%c')\r\n", c, c); } if (c == 'q') break; } return 0; } ``` 你可以读入一个文本来测试 get 的错误信息:`./kilo `,第二节里我们也讲过,我们可以通过向终端写入以`[`开头的转义序列来执行各种渲染和移动光标的任务。本项目使用VT100转义序列,它兼容了市面上绝大部分的终端,具体可以参阅[VT100用户手册](https://vt100.net/docs/vt100-ug/chapter3.html)。 清屏就是使用了其中的**J命令**来擦除显示: - 2J:清除整个屏幕 - 1J:清除屏幕直到光标位置 - 0J:默认参数,清楚光标到屏幕末尾的位置 清屏后光标会停留在屏幕底部,我们使用**H命令**将其重新定位在左上角,使用其默认值1定位在第一行第一列(与C语言不同,其索引从1开始)。 如果你想定位在其他地方,可以使用例如`\x1b[12;40H`这样的命令确定具体位置(前一个参数是列也就是横向的,后一个是行,与习惯相同)。 ## 获取窗口大小 定义全局结构体保存编辑器状态,同时替换全部的`orig_termios`为`E.orig_termios`: ```c struct editorConfig { struct termios orig_termios; }; struct editorConfig E; ``` 大多数系统上都可以通过使用 TIOCGWINSZ 请求调用`ioctl()`来获取终端的大小。 ```c #include struct editorConfig { struct termios orig_termios; int screenrows; int screencols; }; int getWindowSize(int *rows, int *cols) { struct winsize ws; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0) { return -1; } else { *cols = ws.ws_col; *rows = ws.ws_row; return 0; } } void initEditor() { if (getWindowSize(&E.screenrows, &E.screencols) == -1) { die("getWindowSize"); } } int main() { enableRawMode(); initEditor(); while (1) { editorRefreshScreen(); editorProcessKey(); } return 0; } ``` ## 获取光标位置 **n命令+参数6**可以查询光标位置,并返回一个以'R'结尾的回复,所以我们可以用`sscanf`从形如`\x1b[22;90`的转义序列中得到行列的数值: ```c int getCursorPosition(int *rows, int *cols) { char buf[32]; unsigned int i = 0; if (write(STDOUT_FILENO, "\x1b[6n", 4) != 4) { return -1; } while(i < sizeof(buf) - 1) { if (read(STDOUT_FILENO, &buf[i], 1) != 1) { break; } if (buf[i] == 'R') { break; } i++; } buf[i] = '\0'; if (buf[0] != '\x1b' || buf[1] != '[') { return -1; } if (sscanf(&buf[2], "%d;%d", rows, cols) != 2) { return -1; } return 0; } ``` ## 再次获取窗口大小 一些不能使用`iotcl()`的系统我们可以将光标定位在屏幕右下角,然后查询光标位置来判断屏幕上有多少行和列。 **C命令**向右移动光标,**B命令**向下移动光标,使用一个非常大的数确保光标达到屏幕的右下角(这也是H命令不适用的原因,它不能阻止光标越过边界,而C和B可以): ```c int getWindowSize(int *rows, int *cols) { struct winsize ws; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0) { if(write(STDOUT_FILENO, "\x1b[999C\x1b[999B", 12) != 12) { return -1; } return getCursorPosition(rows, cols); } else { *cols = ws.ws_col; *rows = ws.ws_row; return 0; } } ``` ## Append buffer 为了避免多次调用`write()`,我们写一个拼接字符串的方法将小段字符拼接起来,最后只调用一次`write()`写入。 C没有面向对象,我们写一个构造体,然后定义一个常量空缓冲区充当我们的构造函数: ```c struct abuf { char *b; int len; }; #define ABUF_INIT {NULL, 0} ``` 然后添加`append`和`free`两个方法,使用`realloc()`确保足够的内存存储新字符串,`memcpy()`拼接: ```c #include void abAppend(struct abuf *ab, const char *s, int len) { char *new = realloc(ab->b, ab->len+len); if (new == NULL) { return; } memcpy(&new[ab->len], s, len); ab->b = new; ab->len += len; } void abFree(struct abuf *ab) { free(ab->b); } ``` 然后修改刷新屏幕的函数: ```c void editorRefreshScreen() { struct abuf ab = ABUF_INIT; abAppend(&ab, "\x1b[2J", 4); abAppend(&ab, "\x1b[H", 3); write(STDOUT_FILENO, ab.b, ab.len); abFree(&ab); } ``` ## ~ 开始画东西了,我们主要模仿老大哥vim! ![image.png](https://gitee.com/mancuojie/typo/raw/master/202201062102709.png) 首先画终端左边一行一个的波浪号,唯一要注意的就是最后一行不能使用`\r\n`,否则会导致终端滚动使最后一行失去波浪号。 其次:与其每次刷新清除整个屏幕,不如在画一行清除一行,所以使用**K命令**擦除当前行: - 2K:擦除整行 - 1K:擦除光标左侧的部分 - 0K:默认参数,擦除光标右侧的部分 ```c void editorDrawRows(struct abuf *ab) { int y; for (y = 0; y < E.screenrows; y++) { abAppend(ab, "~", 1); abAppend(ab, "\x1b[K", 3); if (y < E.screenrows -1) { abAppend(ab, "\r\n", 2); } } } void editorRefreshScreen() { struct abuf ab = ABUF_INIT; abAppend(&ab, "\x1b[H", 3); editorDrawRows(&ab); write(STDOUT_FILENO, ab.b, ab.len); abFree(&ab); } ``` ## 隐藏光标 当我们在终端绘制时,光标可能会出现屏幕中间的某处闪烁。 为了避免这种情况,我们使用**l以及h命令**在刷新屏幕前隐藏光标,刷新完成后再显示: ```c void editorRefreshScreen() { struct abuf ab = ABUF_INIT; abAppend(&ab, "\x1b[?25l", 6); abAppend(&ab, "\x1b[H", 3); editorDrawRows(&ab); abAppend(&ab, "\x1b[H", 3); abAppend(&ab, "\x1b[?25h", 6); write(STDOUT_FILENO, ab.b, ab.len); abFree(&ab); } ``` ## 欢迎信息 学就要学的像一点,我们使用`snprintf`在屏幕三分之一处打印欢迎信息,包括名称,版本号以及作者。 同时定义一个函数来计算居中距离并用空格填充: ```c void editorCenter(struct abuf *ab, int len) { int padding = E.screencols / 2 - len / 2; while (padding--) { abAppend(ab, " ", 1); } } void editorDrawText(struct abuf *ab, char *str) { char buf[80]; int len = snprintf(buf, sizeof(buf), "%s", str); len = len > E.screencols ? E.screencols : len; // 超出终端大小时截断 editorCenter(ab, len); abAppend(ab, buf, len); } void editorDrawRows(struct abuf *ab) { int y; for (y = 0; y < E.screenrows; y++) { abAppend(ab, "~", 1); if (y == E.screenrows/3 - 1) { editorDrawText(ab, "Kilo - A text editor"); } if (y == E.screenrows/3) { editorDrawText(ab, "version 0.0.1"); } if (y == E.screenrows/3 + 1) { editorDrawText(ab, "by Mancuoj"); } abAppend(ab, "\x1b[K", 3); if (y < E.screenrows -1) { abAppend(ab, "\r\n", 2); } } } ``` 最终效果: ![image.png](https://gitee.com/mancuojie/typo/raw/master/202201062106741.png) ## 移动光标 定义然后初始化光标位置: ```c struct editorConfig { struct termios orig_termios; int screenrows; int screencols; int cx; int cy; }; ... void initEditor() { if (getWindowSize(&E.screenrows, &E.screencols) == -1) { die("getWindowSize"); } E.cx = 0; E.cy = 0; } ``` 然后在刷新屏幕的函数中同步刷新光标位置: ```c void editorRefreshScreen() { 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); } ``` 枚举中把箭头键设置为大于128的数(不与ASCII码冲突),第一个为1000,后面会自动获得递增值。 修改读取按键的函数,读取转义序列`27[`+ABCD即可: ```c enum editorKey { ARROW_LEFT = 1000, ARROW_RIGHT, ARROW_UP, ARROW_DOWN }; // 注意函数返回值改为int int editorReadKey() { int nread; char c; while ((nread = read(STDIN_FILENO, &c, 1)) != 1) { if (nread == -1 && errno != EAGAIN) { die("read"); } } if (c == '\x1b') { char seq[3]; if (read(STDIN_FILENO, &seq[0], 1) != 1) { return '\x1b'; } if (read(STDIN_FILENO, &seq[1], 1) != 1) { return '\x1b'; } if (seq[0] == '[') { switch(seq[1]) { case 'A': return ARROW_UP; case 'B': return ARROW_DOWN; case 'C': return ARROW_RIGHT; case 'D': return ARROW_LEFT; } } return '\x1b'; } else { return c; } } ``` 读取后处理按键值,同时加入一些边界检查防止移出屏幕: ```c 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.screenrows - 1) { E.cy++; } break; } } void editorProcessKey() { int c = editorReadKey(); // 此处char也改为int switch (c) { case CTRL_KEY('q'): write(STDOUT_FILENO, "\x1b[2J", 4); write(STDOUT_FILENO, "\x1b[H", 3); exit(0); break; case ARROW_UP: case ARROW_DOWN: case ARROW_LEFT: case ARROW_RIGHT: editorMoveCursor(c); break; } } ``` ## 加入更多按键 除了方向键以外,我们还需要`Page Up`,`Page Down`,`Home`,`End`以及`Del`键。 - `Page Up`,`Page Down`以分别是:` [5~` ,`[6~`,功能则是模仿用户输入上下箭头,从而上下滚动屏幕。 - `Home`和`End`有多种情况,我们需要分别判定然后全部处理: `[1~`,`[7~`,`[H`,`OH`,`[4~`,`[8~`,`[F`,`OF`,功能分别是跳转到第一列和最后一列。 - `Del`键对应的转义序列为`[3~`,我们暂时不为它添加任何功能。 ```c int editorReadKey() { int nread; char c; while ((nread = read(STDIN_FILENO, &c, 1)) != 1) { if (nread == -1 && errno != EAGAIN) die("read"); } if (c == '\x1b') { char seq[3]; if (read(STDIN_FILENO, &seq[0], 1) != 1) return '\x1b'; if (read(STDIN_FILENO, &seq[1], 1) != 1) return '\x1b'; if (seq[0] == '[') { if (seq[1] >= '0' && seq[1] <= '9') { if (read(STDIN_FILENO, &seq[2], 1) != 1) return '\x1b'; if (seq[2] == '~') { switch (seq[1]) { case '1': return HOME_KEY; case '3': return DEL_KEY; case '4': return END_KEY; case '5': return PAGE_UP; case '6': return PAGE_DOWN; case '7': return HOME_KEY; case '8': return END_KEY; } } } else { switch (seq[1]) { case 'A': return ARROW_UP; case 'B': return ARROW_DOWN; case 'C': return ARROW_RIGHT; case 'D': return ARROW_LEFT; case 'H': return HOME_KEY; case 'F': return END_KEY; } } } else if (seq[0] == 'O') { switch (seq[1]) { case 'H': return HOME_KEY; case 'F': return END_KEY; } } return '\x1b'; } else { return c; } } ... void editorProcessKey() { 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: { 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; } } ``` # 4. A text viewer ## 初始化 为了查看文件内容,首先我们创建一些属性同时在函数中进行初始化: - `erow`:存储我们读取的文件一行的字符串及其大小,创建数组来存储每一行的内容 - `numrows`:已经存储的行数 - `rowoff`:记录行偏移值,方便用户向下滚动浏览 ```c // editor row typedef struct erow { int size; char *chars; } erow; struct editorConfig { ... erow *row; int numrows; int rowoff; }; struct editorConfig E; void initEditor() { ... // 初始化 E.numrows = 0; E.row = NULL; E.rowoff = 0; } ``` ## 读取文件内容 通过`fopen()`打开文件,`getline()`循环读取每一行内容,它的返回值是读取的行的长度,如果它的文件的末尾并且没有更多的行,则返回-1,循环结束。 `editorAppendRow()`接收`getline()`传递的每一行的内容及大小,然后通过`realloc()`为其分配内存,添加`'\0'`做为字符串复制到erow数组`E.row`中存储。 ```c #include 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++; } 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) { // 循环读取每一行内容,直到末尾返回-1 while (linelen > 0 && (line[linelen - 1] == '\n' || line[linelen - 1] == '\r')) { // 去除每行最后的换行府或回车符 linelen--; } editorAppendRow(line, linelen); } free(line); fclose(fp); } ``` 为了让我们的代码更具可移植性,在最上方添加三行代码: ```C #define _DEFAULT_SOURCE #define _BSD_SOURCE #define _GNU_SOURCE ``` ## 显示文件内容 上一步我们读取文件内容并且存储到了`E.row`中,这一步我们要用`editorDrawRows()`来把它们显示出来。 在欢迎信息处加入更多判断,然后使用`abAppend()`显示文件内容: ```c void editorDrawRows(struct abuf *ab) { int y; for (y = 0; y < E.screenrows; y++) { int filerow = y + E.rowoff; // 加上行偏移值,方便下一步设置垂直滚动 if (filerow >= E.numrows) { abAppend(ab, "~", 1); if (y == E.screenrows/3 - 1 && E.numrows == 0) { editorDrawText(ab, "Kilo - A text editor"); } if (y == E.screenrows/3 && E.numrows == 0) { editorDrawText(ab, "version 0.0.1"); } if (y == E.screenrows/3 + 1 && E.numrows == 0) { editorDrawText(ab, "by Mancuoj"); } } 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); } } } ``` 大功告成,我们可以输入`./kilo kilo.c`来查看结果: ![image-20220109111932449](https://gitee.com/mancuojie/typo/raw/master/202201091119598.png) ## 垂直滚动 我们通过检查光标是否移出当前可见窗口来设置`E.rowoff`的值,修改光标向下移动条件为`E.cy < E.numrows`: ```c void editorMoveCursor(int key) { switch (key) { ... case ARROW_DOWN: if (E.cy < E.numrows) { E.cy++; } break; } } ``` 移出当前窗口则调整光标位置,同时因为现在`E.cy`指光标在文件中的位置,而不是屏幕位置,所以向上滚动会有些问题,我们也修改一下: ```c 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 editorRefreshScreen() { editorScroll(); ... char buf[32]; snprintf(buf, sizeof(buf), "\x1b[%d;%dH", (E.cy - E.rowoff) + 1, E.cx + 1); abAppend(&ab, buf, strlen(buf)); ... } ``` 再次运行`./kilo kilo.c`,发现已经可以滚动浏览整个文件了(如果查看的文件包含制表符`tab`会有一些问题,后面会解决)。 ## 水平滚动 水平滚动与垂直滚动类似,我们只需要一遍相同的流程即可: 1. 设置`coloff` 2. 修改光标移动条件 3. 移出窗口时调整光标位置 4. 修复光标位置 5. 修改绘制函数 ```c // 类比垂直滚动定义属性,只需要把row换成col struct editorConfig { ... int numrows; erow *row; int rowoff; int coloff; }; 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) { ... } 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); } ... } void editorRefreshScreen() { ... snprintf(buf, sizeof(buf), "\x1b[%d;%dH", (E.cy - E.rowoff) + 1, (E.cx - E.coloff) + 1); ... } void editorMoveCursor(int key) { switch (key) { ... // 删除其限制条件 case ARROW_RIGHT: E.cx++; break; ... } } void initEditor() { ... E.rowoff = 0; E.coloff = 0; } ``` ## 限制光标位置 我们需要对文件中的光标位置进行限制,防止无意义的操作。 使`E.cx`只可以指向行尾之后的一个字符,`E.cy`只可以在行尾之后的一行。 ```c 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; } } ``` 但是用户仍可以通过将光标移动到长行的末尾,然后再移动到较短的行来使光标超过指定位置。 所以我们需要在`E.cx`位于该行末尾右侧时,将其设置为该行末尾。 同时因为`E.cy`可能指向与之前不同的行,我们要再次设置row。 ```c 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 len = row ? row->size : 0; if (E.cx > rowlen) { E.cx = rowlen; } } ``` ## 行首尾的移动 允许用户在行首按 ← 移动到上一行的结尾,在行尾按 → 以转到下一行的开头。 ```c void editorMoveCursor(int 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; ... } ``` ## 制表符tab 如果使用`./kilo Makefile`查看该文件,你会发现第二行的制表符占据了8列左右的宽度: ![image-20220111150906491](https://gitee.com/mancuojie/typo/raw/master/202201111509663.png) 制表符的长度取决于所使用的终端和设置,我们想要自己控制它的渲染方式,所以我们要自己写一个渲染函数(后面也可以用来打印控制字符,如把`Ctrl-A`呈现为`^A`)。 定义以及初始化,使用渲染函数读取每一行: ```c typedef struct erow { int size; char *chars; int rsize; char *render; } erow; void editorAppendRow(char *s, size_t len) { ... E.row[at].rsize = 0; E.row[at].render = NULL; editorUpdateRow(&E.row[at]); E.numrows++; } ``` 然后创建该渲染函数,将`chars`中的字符复制到`render`中,如果行包含tab也就是`\t`则: 1. 统计`\t`数量 2. 设置一个常量代表tab键的长度 3. 分配内存,减1是因为`row->size`已经将每个tab记为1 4. 将`\t`转为空格 ```c #define KILO_TAB_STOP 4 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; } ``` 现在我们可以通过修改`KILO_TAB_STOP`的值来自由调节制表符的长度了。 但是运行`./kilo Makefile`时,我们会发现光标并不能和制表符交互,它仍会把它认为是几列,而不是当作一列处理。 为了解决这个问题,我们引入`E.rx`代表render的索引,与`E.cx`代表chars的索引类似。 如果当前行没有制表符,那么`E.rx`与`E.cx`相同,如果有`E.rx`将比`E.cx`大。 - 定义并初始化 ```c struct editorConfig { ... int rx; }; void initEditor() { ... E.rx = 0; } ``` - 替换掉cx ```c void editorScroll() { E.rx = E.cx; ... if (E.rx < E.coloff) { E.coloff = E.rx; } if (E.rx >= E.coloff + E.screencols) { E.coloff = E.rx - E.screencols + 1; } } void editorRefreshScreen() { editorScroll(); ... snprintf(buf, sizeof(buf), "\x1b[%d;%dH", (E.cy - E.rowoff) + 1, (E.rx - E.coloff) + 1); ... } ``` - 将cx转换成rx ```c 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; } ``` - 再次修改滚动函数 ```c 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; } ... } ``` ## 修改按键功能 1. 在按下`PageUp`或`PageDown`时同步`E.cy` 2. 按下`End`后将光标移动到行末,`Home`键不需要改动,因为我们已经使`E.cx`基于文件而不是屏幕,如果没有当前行,那就保持为0 ```c void editorProcessKey() { ... 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; ... } ``` ## 状态栏 We need a status bar!我们会用它显示有用的信息,比如文件名,文件中有多少行以及目前所在的行。 - 把屏幕底部空出一行 ```c void editorDrawRows(struct abuf *ab) { ... // 删除限制条件,因为现在最后一行是状态栏可以直接换行 abAppend(ab, "\r\n", 2); } } void initEditor() { ... E.screenrows -= 1; // 直接让终端减少一行 } ``` - 反转颜色,白底黑字,突出显示 ```c 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() { ... editorDrawRows(&ab); editorDrawStatusBar(&ab); ... } ``` **m命令**可以使文本具有各种属性,参数1粗体,4下划线,5闪烁和7反色(甚至你可以同时设置所有属性),而默认参数0可以清除所有属性。所以我们使用`[7m` 切换到反转颜色,并使用`[m` 切换回正常格式。 - 增加显示内容 ```c // 显示文件名,还是熟悉的定义以及初始化 struct editorConfig { ... char *filename; }; void editorOpen(char *filename) { free(E.filename); E.filename = strdup(filename); FILE *fp = fopen(filename, "r"); ... } void initEditor() { ... E.screenrows -= 1; E.filename = NULL; } ``` - 展示 ```c 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); } ``` ## 用户提示信息 我们将在状态栏下再添加一行,以便向用户显示提示信息,并在搜索时提示用户输入。 我们将消息放入全局可编辑状态,同时为其存储一个时间戳,以便我们可以在它显示几秒钟后将其删除。 - 再空出一行 ```c void editorDrawStatusBar(struct abuf *ab) { ... abAppend(ab, "\x1b[m", 3); abAppend(ab, "\r\n", 2); } void initEditor() { ... E.screenrows -= 2; E.filename = NULL; } ``` - 定义及初始化 ```c #include struct editorConfig { ... char statusmsg[80]; time_t statusmsg_time; }; void initEditor() { ... E.statusmsg[0] = '\0'; E.statusmsg_time = 0; } ``` - 设置提示信息 ```c #include //此处...为可变参数类型 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); } int main(int argc, char *argv[]) { ... if (argc >= 2) { editorOpen(argv[1]); } editorSetStatusMessage("HELP: Ctrl-Q = quit"); while (1) { ... } ``` - 显示 ```c void editorDrawMessageBar(struct abuf *ab) { // 清除消息栏 abAppend(ab, "\x1b[K", 3); int msglen = strlen(E.statusmsg); if (msglen > E.screencols) msglen = E.screencols; // 消息必须小于5秒 if (msglen && time(NULL) - E.statusmsg_time < 5) { abAppend(ab, E.statusmsg, msglen); } } void editorRefreshScreen() { ... editorDrawRows(&ab); editorDrawStatusBar(&ab); editorDrawMessageBar(&ab); ... } ``` 现在启动程序时,您应该会在底部看到帮助消息,5秒后按下一个键就会消失。但是请记住,我们只在每次按键后才刷新屏幕,你不按就会一直显示。 # 5. A text editor ## 插入字符 让我们首先编写两个函数,在给定位置将单个字符插入到 erow 中。 ```c void editorAppendRow(char *s, size_t len) { } void editorRowInsertChar(erow *row, int at, int c) { if (at < 0 || at > row->size) at = row->size; row->chars = realloc(row->chars, row->size + 2); memmove(&row->chars[at + 1], &row->chars[at], row->size - at + 1); // 类似于memcpy(),在源与目标数组相同时使用 row->size++; row->chars[at] = c; editorUpdateRow(row); } void editorInsertChar(int c) { if (E.cy == E.numrows) { // 去除~ editorAppendRow("", 0); } editorRowInsertChar(&E.row[E.cy], E.cx, c); E.cx++; } ``` 在处理按键时默认调用该函数,现在运行已经可以在文件中插入字符了。 ```c void editorProcessKey() { ... case ARROW_RIGHT: editorMoveCursor(c); break; default: editorInsertChar(c); break; } } ``` ## 处理特殊按键 现在运行程序,按下`Enter`,`Backspace`等键,这些字符将直接插入到文本中,我们需要在`editorProcessKey()`中处理这些特殊按键: