此篇記錄學習過程
---
<!-- [Github](https://github.com/leonnig/easy_txt_editor) -->
# Entering raw mode
做一個文字編輯器,我們首先要讓使用者能夠有輸入文字的功能。
```c
#include <unistd.h>
int main()
{
char c;
while (read(STDIN_FILENO, &c ,1) == 1);
return 0;
}
```
read 函式原型如下,可參考 [read/write](https://shihyu.github.io/books/ch28s04.html)
>ssize_t read(int fd, void *buf, size_t count)
`STDIN_FILENO` 是一個在 POSIX 系統(如 Linux、macOS)中預定義的常數,代表「標準輸入」的檔案描述符(file descriptor。
我們首先使用 `read` 去讀取 1 byte 到變數中c,並一直執行此操作,直到沒有更多 bytes 可讀取。
若要退出上述程序,請按 Ctrl-D 以告知 `read()` 已到達文件末尾。或者可以隨時按下 Ctrl-C 以發出信號讓 process 立即終止。
再來我們手動添加讓使用者按下 `'q'` 即可以退出的功能
```diff
int main()
{
char c;
+ while (read(STDIN_FILENO, &c ,1) == 1);
return 0;
}
```
## Turn off ecohing
接下來我們引入 `termios.h` 來設定終端機的屬性,先使用 `tcgetattr()`將目前的屬性讀入 termios 結構體中,再來手動修改結構體,將其中的 `ECHO` 做 inverse(相當於關閉),最後將修改後的結構體用 `tcsetattr()` 將新的終端機屬性傳遞回去。
參考: [tcgetattr()](https://pubs.opengroup.org/onlinepubs/007904975/functions/tcgetattr.html)
```c
#include <unistd.h>
#include <termios.h>
void enableRawMode()
{
struct termios raw;
tcgetattr(STDERR_FILENO, &raw);
raw.c_lflag &= ~(ECHO);
tcsetattr(STDERR_FILENO, TCSAFLUSH, &raw);
}
int main()
{
enableRawMode();
char c;
while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q');
return 0;
}
```
參考:[termios.h](https://github.com/openbsd/src/blob/master/sys/sys/termios.h)
這個 `ECHO` 功能會將使用者輸入的每個按鍵內容列印到終端機上,但我們今天想要在 **rawmode** 下呈現使用者介面時,就會造成阻礙,所以我們把它拿掉,所以當我們在輸入內容的時候,終端機上是不會顯示我們所輸入的內容的,但它仍然有讀取到,相當於輸入密碼。
當程式退出後,可能會發現終端機仍然沒有出現輸入的內容。但它其實仍然有接收到輸入的內容。
只需按下Ctrl-C即可在 shell 中啟動新的輸入行,然後輸入reset並按下Enter,大多數情況下,這會將您的終端恢復正常。如果失敗,可以隨時重新啟動終端。
## 退出時禁用rawmode
我們做出一些修改,讓使用者在退出程式時可以恢復原本的終端屬性。
所以一開始要先將原始的 `termios` 的結構體狀態保存起來。
```c
#include <unistd.h>
#include <termios.h>
#include <stdlib.h>
struct termios orig_termios; //original termios
void disableRawMode()
{
tcsetattr(STDERR_FILENO, TCSAFLUSH, &orig_termios);
}
void enableRawMode()
{
tcgetattr(STDERR_FILENO, &orig_termios);
atexit(disableRawMode); //call the function when exit
struct termios raw = orig_termios;
raw.c_lflag &= ~(ECHO);
tcsetattr(STDERR_FILENO, TCSAFLUSH, &raw);
}
```
[atexit()](https://man7.org/linux/man-pages/man3/atexit.3.html) 是來自 `stdlib`, 可以註冊一個函式,在程式結束時呼叫,無論它是透過從 return 來退出main(),還是透過呼叫exit() 退出,這樣我們就能確保程式退出時,我們的終端機屬性可以保持原樣。
我們先將初始的屬性保存在另一個全域變數 `orig_termios` 中,等程式結束在使用 `tcgetattr` 來取得它的屬性。
這裡留意一下,程式退出後剩餘的輸入不再輸入到您的 shell 中,這是因為 `TCSAFLUSH`,我們看一下手冊
> The change occurs after all output written to the object referred by fd has been transmitted,and all input that has been received but not read will be discarded before the change is made.
裡面說到,所有未讀取的輸入都會在變更到終端之前被捨其掉,所以我們退出程式時,就算有先前打在終端上但沒 Enter 的內容,也不會顯示出來。
[**termios.h**](https://www.ibm.com/docs/zh-tw/aix/7.3?topic=files-termiosh-file)
## 關閉 canonical(規範) 模式
我們可以藉由 `ICANON` flag 來關閉 canonical 模式,`ICANON` 是termios 的一個控制 flag。
**Canonical mode** (熟悉的 line-buffered mode) → 終端機預設是 line-by-line 模式,只有你按下 Enter 之後,程式才會收到整行輸入,而關閉 canonical mode 則可以讓程式即時(逐字節)讀取輸入,不需要等待使用者按 Enter。
```c
raw.c_lflag &= ~(ECHO | ICANON);
```
現在按下 `q` 之後不用輸入 enter 就可以馬上退出編輯器。
## Display keypresses
現在 `read(STDIN_FILENO, &c, 1)` 每次能讀進來 1 byte,接著使用 `iscntrl()` 來確認讀進來的字元是否為控制字元,若是控制字元,就只輸出其對應 ASCII,否則輸出 ASCII + 該字元。
:::info
ASCII 表:
0–31, 127 = 控制字元(例如 Ctrl-A, Ctrl-B, Backspace)。
32–126 = 可列印字元(空白、數字、字母、符號)。
:::
```c
char c;
while (read(STDIN_FILENO, &c, 1) == 1 && c != 'c') {
if (iscntrl(c)) {
printf("%d\n", c);
} else {
printf("%d ('%c')\n", c, c);
}
}
```
但如果按下了 `Ctrl-c` 會讓程式中止,而 `Ctrl-z` 則會讓將程式放到背景執行,所以要先將這兩種情況排除,讓這兩個也能夠被印出,需要將 `ISIG` 關閉,這樣可以關閉由鍵盤產生的訊號像是 `Ctrl-C` 的 `SIGINT`、`Ctrl-Z` 的 `SIGTSTP` 等。
```c
raw.c_lflag &= ~(ECHO | ICANON | ISIG);
```
除此之外,還需要關閉 **software flow control**,這是一種用來資料發送的機制,在舊時代,終端機或印表機處理速度慢時,可以用 `Ctrl-S (XOFF)` 來暫停資料傳輸,再用 `Ctrl-Q (XON)` 恢復,而我們要讓 `Ctrl-S / Ctrl-Q` 不再被系統中斷,而是交由程式自行處理。
`IXON` 是 `c_iflag(input flags)`的一個選項,將其關閉就代表停用 `XON/XOFF`。
```c
raw.c_iflag &= ~(IXON);
```
## Turn off all output processing
終端機在輸出時,其實也會做自動轉換,也就是原本每個輸入跟輸入之間,他應該只會做 `\n` 也就是換行,但終端機自動幫忙轉換成 `\r\n` ,也就是除了換行外,還將游標移到最前面開頭,而 raw mode 的目的就是要完全掌控我們的編輯器,我們希望每個輸出字元、游標移動、刪除動作都由程式控制,而不是交由終端機,所以我們將 `OPOST` flag 給關閉。
```c
raw.c_oflag &= ~(OPOST);
```
現在執行程式,會發現我們印出的換行符只會將遊標向下移動,而不會移動到螢幕左側。為了解決這個問題,我們必須自己在每個換行前面加上 `\r`:
```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);
}
}
```
## A timeout for `read()`
目前我們的 `read()` 會一直等待直到使用者輸入才會做 return,但要是在使用者沒有輸入的情況下,又想要在背景做一些其他事情,像是播放背景動畫之類的,豈不是被堵塞住了,我們可以設定一個超時時間(很短的時間),這樣 `read()` 如果在一定時間內沒有收到任何輸入,它就會回傳。
#### VMIN & VTIME
參考文章 :[VMIN & VTIME](http://sites.xms.com.tw/board.php?courseID=143&f=doc&folderID=1224&cid=8627)
VMIN 與 VTIME 是 struct termios 的控制位元,在 `c_cc[]` 陣列裡。
VMIN 代表 滿足 `read()` 的最低字元接收個數。
VTIME 代表 `read()` 返回前所等待的最長時間。
我們可以將 VMIN 設定為 0,代表只要有任何輸入資料可讀,就立刻 return,而且不需要湊滿特定的 byte 數,而 VTIME 的值的單位是十分之一秒,所以我們設為 1(代表 0.1 秒,也就是 100 毫秒)。如果 `read()` 超時,它會返回 0,這很合理,因為它通常的返回值是實際讀取到的位元組數。
```c
raw.c_cc[VMIN] = 0;
raw.c_cc[VTIME] = 1;
```
當你執行這個程式時,可以觀察到 `read()` 超時的頻率。如果你沒有輸入任何東西,`read()` 會直接返回,且不會設定變數 c,這時 c 會保留其原本的 0 值,因此當沒有任何輸入時,會看到編輯器一直印出 0。
記得將
```c
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);
}
}
```
改為
```c
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;
}
```
因為在 VMIN=0、VTIME > 0 的情境下,`read()` 會經常回傳 0(代表 timeout),而不是每次都讀到一個有效字元。這會影響原本的 while 條件判斷方式。
## Error handling
我們將新增一個 `die()` 來印出錯誤訊息並退出程式。
```c
void die(const char *s) {
perror(s);
exit(1);
}
```
大多數 fail 的 C library finction 都會設定全域 errno 變數來指示錯誤原因。 `perror()`可以查看全域 errno 變數並印出其錯誤訊息,印出錯誤訊息後,我們用退出狀態 1 結束程式,這代表失敗(任何非零值都代表失敗)。
接下來我們在每個函式呼叫去檢查是否失敗,並在失敗時加上 `die()`
```c
void disableRawMode() {
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios) == -1)
die("tcsetattr");
}
void enableRawMode() {
if (tcgetattr(STDIN_FILENO, &orig_termios) == -1) die("tcgetattr");
...
...
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) die("tcsetattr");
}
if (read(STDIN_FILENO, &c, 1) == -1 && errno != EAGAIN) die("read");
```
# Raw input and output
## Press Ctrl-Q to quit
可以發現 Ctrl 加上 26 個字母可以分別對應到 26 個輸出數字,因此我們可以利用這點將 quit 設為 `Ctrl-Q`,這樣 Q 鍵就能作為正常輸入使用了。
定義巨集利用 bitwise 操作來確認輸入是否為 `Ctrl-Q`
```c
#define CTRL_KEY(k) ((k) & 0x1f)
.
.
.
if (c == CTRL_KEY('q')) break;
```
## Refactor keyboard input
再來我們將鍵盤輸入的部分作模組化,首先將讀取的部分單獨拆開來
```c
char editorReadkey()
{
int nread;
char c;
while ((nread = read(STDIN_FILENO, &c, 1)) != 1) {
if (nread == -1 && errno != EAGAIN) die("read");
}
return c;
}
```
`editorReadkey()` 的作用是等待一次按鍵輸入,然後再返回。
```c
/*** input ***/
void editorProcessKeypress()
{
char c = editorReadkey();
switch (c) {
case CTRL_KEY('q'):
exit(0);
break;
}
}
```
`editorProcessKeypress` 的作用是等待 read 的返回,然後處理它,之後,會將各種 Ctrl 組合鍵和其他特殊鍵 mapping 到不同的編輯器功能。
## Clear the screen
我們需要再每次輸入後去重新繪製編輯器畫面,這樣看起來才像是開啟了一個文字編輯器,而一開始的第一步是清除整個終端畫面
```c
/*** output ***/
void editorRefreshScreen ()
{
write(STDOUT_FILENO, "\x1b[2J", 4);
}
.
.
.
while (1) {
editorRefreshScreen();
editorProcessKeypress();
}
```
接下來解釋一下 `write(STDOUT_FILENO, "\x1b[2J", 4)` 到底做了甚麼
`write` 作用為直接把 bytes 寫到標準輸出,`STDOUT_FILENO` 代表標準輸出檔描述子,`\x1b[2J` 則是一個 [escape sequence](https://zh.wikipedia.org/zh-tw/%E8%BD%AC%E4%B9%89%E5%BA%8F%E5%88%97) 類似 ESC 之類的功能
- `\x1b` 是 escape 字元(十六進位 0x1B,十進位 27),常寫成 ESC。
- 接著是 [ 字元,然後 2(參數)、最後 J(命令)
- 4 代表寫入 4 個 bytes
我們向終端機寫入一個 escape sequence。而 escape sequence 總是以一個跳脫字元(27)開頭,接著是一個 [ 字元。escape sequence 會指示終端機執行各種文字格式化任務,例如上色文字、移動游標位置、以及清除畫面的一部分。
這邊使用的是 J 指令(Erase In Display)來清除螢幕。escape sequence 的指令可以帶有參數,這些參數會出現在指令之前。在這個例子中,參數是 2,表示要清除整個螢幕。
## Reposition the cursor
但目前清除後游標會留在底部,而我們希望每次開啟編輯器時游標都可以自動在最左上角
我們可以使用 H 指令 (Cursor Position) 來定位游標的位置,H 指令實際上接受兩個參數要,將游標放置的列號與行號,H 指令的兩個預設參數都是 1,所以我們可以省略這兩個參數,這樣游標就會被放在第一列、第一行,也就是最左上角的位置。
```c
void editorRefreshScreen ()
{
write(STDOUT_FILENO, "\x1b[2J", 4);
write(STDOUT_FILENO, "\x1b[H", 3);
}
```
在程式結束時也別忘了要進行刷新
```c
void editorProcessKeypress() {
char c = editorReadKey();
switch (c) {
case CTRL_KEY('q'):
write(STDOUT_FILENO, "\x1b[2J", 4);
write(STDOUT_FILENO, "\x1b[H", 3);
exit(0);
break;
}
}
```
## Tildes
接下來就像 vim 編輯器那樣,在 first column 都印上波浪號
```c
void editorDrawRows ()
{
for (int i = 0; i < 24; i++) {
write(STDERR_FILENO, "~\r\n", 3);
}
}
void editorRefreshScreen ()
{
write(STDOUT_FILENO, "\x1b[2J", 4);
write(STDOUT_FILENO, "\x1b[H", 3);
editorDrawRows(); //放在 refresh 裡面,跟著一同刷新
write(STDOUT_FILENO, "\x1b[H", 3);
}
```
## 取得終端機視窗大小
我們先宣告一個全域的資料結構來儲存終端機的狀態,以及視窗的長寬值。
```c
struct editorConfig {
int screenrows;
int screencols;
struct termios orig_termios; //original termios
};
struct editorConfig E;
```
在大多數系統中,你只需要呼叫 `ioctl()` system call,並傳入參數 `TIOCGWINSZ`,就可以取得終端機的大小。
```c
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;
}
}
```
當呼叫成功時會將終端機的屬性(長寬)存到 winsize 結構體中,接下來可以寫一個初始化的函式,讓這個屬性可以傳遞到全域的終端機結構體中
```c
void initEditor() {
if (getWindowSize(&E.screenrows, &E.screencols) == -1) die("getWindowSize");
}
```
而得到正確的屬性後就可以印出波浪號了