完成了檔案載入與顯示後,接著要加入字元插入、刪除與換行,將修改後的內容安全寫回磁碟,並把每次修改記成可逆操作,讓 Ctrl+Z 與 Ctrl+Y 可以復原及重做。整個編輯與復原流程包含四個關鍵部分:
上一篇在開啟檔案時,已將磁碟內容載入 editor.rows 供顯示使用。當時的 rows []string 是唯讀的;這一篇把它改成可編輯的 text buffer。使用者輸入只修改 buffer,磁碟上的檔案不會立刻改變;按下 Ctrl+S 後,編輯器才把 buffer 寫回磁碟。
範例用 rows []Row 表示 text buffer,每個 Row 保存一行可編輯文字:
type Row struct {
Chars []rune
}
type Editor struct {
rows []Row
cx, cy int
dirty int
}
Row 與 Editor 是兩個 struct 型別。Row 表示單一行的文字內容;Editor 則保存整份文件的列陣列 rows,並用 (cx, cy) 記錄游標在文件中的位置與修改狀態。
在 Go 中,string 是唯讀的,且索引單位是 byte。中文字在 UTF-8 編碼下會占 3 個 byte;若直接對 string 用索引切分,s[0] 只會拿到中文字的三分之一,既無法代表完整字元,也無法直接修改內容。
將每一行改成 []rune 型別(Chars []rune):
[]rune 後只有 2 個元素,Chars[0] 代表「你」、Chars[1] 代表「好」,增刪時不必手動計算 UTF-8 的 byte 長度。當使用者在終端機中輸入一般字元(如字母、數字)或在行內按 Backspace 刪除字元時,編輯範圍僅局限在游標所在的那一行,不會增加或減少文件的總行數。
Editor 只需要透過 cy 找到對應的 Row,再將行內游標 cx 當成索引傳給 Row 的修改方法。當 Row 更新完內部的 Chars 切片後,Editor 再隨之移動游標位置。
假設這一行是 helo,游標位於索引 2,使用者輸入 X。insertRune(2, 'X') 把原本的 slice 在索引 2 切成左右兩段,再把 X 放在中間:
原始內容:he|lo
索引: 0 1 2 3
r.Chars[:2] ch r.Chars[2:]
[h e] + [X] + [l o]
│
▼
[h e X l o]
結果:heX|lo
func (r *Row) insertRune(at int, ch rune) {
r.Chars = append(r.Chars[:at], append([]rune{ch}, r.Chars[at:]...)...)
}
insertRune 將原本的切片在索引 at 處切成左右兩段,並將新字元 ch 放在中間,重組成插入後的新切片。
Editor.insertChar 則負責整合行寫入與游標狀態:
func (e *Editor) insertChar(ch rune) {
if e.cy == len(e.rows) {
e.rows = append(e.rows, Row{})
}
e.rows[e.cy].insertRune(e.cx, ch)
e.cx++
e.dirty++
}
cy == len(rows)),先新增空行。insertRune 在 cx 位置寫入字元,隨後將游標右移一格(cx++),並累加 dirty 記錄一次未存檔修改。游標位於 heX|lo 時按 Backspace,要刪除的是游標左邊索引 2 的 X。Editor 先把 cx-1 傳給 deleteRune,deleteRune(2) 再跳過索引 2,把它前後的內容接起來:
刪除前:heX|lo
索引: 0 1 2 3 4
r.Chars[:2] r.Chars[3:]
[h e] [l o]
└────────────┬─────────────┘
▼
[h e l o]
被刪除:r.Chars[2] = [X]
結果:he|lo
func (r *Row) deleteRune(at int) {
r.Chars = append(r.Chars[:at], r.Chars[at+1:]...)
}
deleteRune 跳過索引 at 的字元,將其前後的切片重新串接,即可移除該字元。
當使用者按下 Backspace 且游標不在行首(cx > 0)時,執行單行刪除:
if e.cx > 0 {
e.rows[e.cy].deleteRune(e.cx - 1)
e.cx--
e.dirty++
}
Backspace 刪除的是游標左邊的字元,因此傳給 deleteRune 的刪除索引是 cx - 1。刪除後將游標左移一格(cx--),並累加 dirty 記錄未存檔狀態。
游標位於 hel|lo 時按 Enter,cx 左邊的 hel 留在目前行,右邊的 lo 移到下一行。游標跟著移到下一行行首,因此 cy 增加 1,cx 歸零:
按下 Enter 前:hel|lo
按下 Enter 後:hel
|lo
切行過程涉及 rows 切片(slice)的插列搬動:
原本行陣列:[Row0, Row1(hel|lo), Row2]
│
┌──────────────────┴──────────────────┐
▼ ▼
Row1(hel) Row1_next(lo)
1. rows 擴容增加一格:[Row0, Row1, Row2, 空白]
2. copy 往下搬動一格:[Row0, Row1, Row2, Row2]
3. 放入新切出的行 :[Row0, Row1, Row1_next, Row2]
func (e *Editor) insertNewline() {
if e.cy == len(e.rows) {
e.rows = append(e.rows, Row{})
}
cur := &e.rows[e.cy]
rest := append([]rune(nil), cur.Chars[e.cx:]...)
cur.Chars = cur.Chars[:e.cx]
e.rows = append(e.rows, Row{})
copy(e.rows[e.cy+2:], e.rows[e.cy+1:])
e.rows[e.cy+1] = Row{Chars: rest}
e.cy++
e.cx = 0
e.dirty++
}
按下 Enter 時的斷行處理包含三個步驟:
rest),左邊的文字則保留在原本這行。rows 中空出 cy + 1 的位置,並放入新產生的行。cy++ 且 cx = 0),並累加 dirty 狀態。這個邏輯能自然涵蓋各種位置:游標在行首按 Enter 時會在目前行上方插入空行;游標在行尾按 Enter 時則會於下方新增空行。
游標在行首(cx == 0)按 Backspace,左邊沒有同一行的字元,不能呼叫 deleteRune(-1)。這時要把目前行接到上一行的尾端:
合併前:hello
|world
合併後:hello|world
實作時先記住上一行的長度,因為這是合併後的游標位置;接著串接兩行的 Chars,再從 rows 刪掉目前行:
prev := &e.rows[e.cy-1]
e.cx = len(prev.Chars) // 游標放在接縫處
prev.Chars = append(prev.Chars, e.rows[e.cy].Chars...)
e.rows = append(e.rows[:e.cy], e.rows[e.cy+1:]...) // 刪掉這一行
e.cy--
Backspace 是刪除游標左邊的字元(向左刪除),而 Delete 則是刪除游標右邊的字元(向右刪除)。
在實作 Delete(deleteForward)時,不需要重寫一套複雜的跨行合併邏輯。只要先將游標向右移動一格,再呼叫 Backspace 的刪除函式(delChar),就能達成刪除右側字元或將下一行合併上來的效果:
func (e *Editor) deleteForward() {
if e.cy >= len(e.rows) {
return
}
if e.cx == len(e.rows[e.cy].Chars) && e.cy == len(e.rows)-1 {
return // 位於檔案最後一行的行尾,無法再向後刪除
}
e.moveCursor(KeyArrowRight)
e.delChar()
}
當使用者按下 Ctrl+S 時,編輯器需要將記憶體中的文字寫回磁碟。
最直覺的作法是呼叫 os.WriteFile 直接覆寫原檔案:
os.WriteFile(e.filename, e.rowsToBytes(), 0644)
但直接覆寫有個風險:若在寫入過程中發生程式當機、斷電或系統崩潰,舊檔案的內容已經被清空,而新內容又沒寫完整,檔案就會永久毀損。
為了解決這個問題,文字編輯器通常採用「先寫臨時檔,再改名覆寫」的安全存檔流程:
tmp.Sync(),通知作業系統立刻將資料寫入實體硬碟,避免資料只留在記憶體快取中。os.Rename 將臨時檔案更名為原本的檔名。作業系統能保證改名動作只會一次成功或失敗,不會留下改到一半的損毀檔案。程式碼實作如下:
func (e *Editor) save() error {
// 1. 在同目錄建立臨時暫存檔
tmp, err := os.CreateTemp(filepath.Dir(e.filename), ".editor-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // 確保失敗時自動刪除臨時檔
// 2. 將記憶體文字寫入臨時檔
if _, err := tmp.Write(e.rowsToBytes()); err != nil {
tmp.Close()
return err
}
// 3. 確保資料已真正寫入硬碟
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
tmp.Close()
// 4. 改名覆寫原檔
if err := os.Rename(tmp.Name(), e.filename); err != nil {
return err
}
e.dirty = 0 // 存檔成功,重置修改狀態
return nil
}
這項機制確保了存檔的安全性:存檔失敗時,錯誤只會顯示在狀態列,未存檔的文字依然安全留在記憶體中供使用者繼續編輯。
Ctrl+Z 用於復原(Undo)上一次的修改,而 Ctrl+Y 則用於重做(Redo)剛被復原的操作。
在 Raw Mode 下,終端機不會把 Ctrl+Z 當成暫停程式的訊號(SIGTSTP),而是會將 Ctrl+Z 作為 0x1a(ASCII 26)、Ctrl+Y 作為 0x19(ASCII 25)傳給程式。我們可以捕捉這兩個按鍵事件,觸發編輯器的 Undo 與 Redo 邏輯。
若每次修改前都複製整份文件的副本,當檔案很大或編輯次數多時,記憶體會快速被多份文件副本吃光。因此我們不需要保存整份文件的歷史狀態,只需記錄每次修改對文字做了什麼改變。
編輯器對 buffer 的所有修改,本質上只有四種基本操作。只要上記錄操作發生的位置與內容,並為每一種操作定義對應的「逆操作」,就能用極小的記憶體還原任何歷史狀態:
type EditOp struct {
Kind OpKind
Cy, Cx int
Text []rune
seq int
}
每種操作都有相對應的「逆操作」:
OpInsert):原操作是在 (Cy, Cx) 插入文字,逆操作即呼叫 OpDelete 刪除該段文字。OpDelete):原操作是刪除文字,逆操作即呼叫 OpInsert 將被刪除的文字插回原處。OpSplit):原操作是按下 Enter 將一行切為兩行,逆操作即呼叫 OpMerge 將下一行合併回來。OpMerge):原操作是將下一行併回上一行尾端,逆操作即呼叫 OpSplit 在原本的接縫處重新切開。例如,當游標位於 (cy: 0, cx: 2) 時輸入 X,編輯器會建立一份紀錄:
op := EditOp{Kind: OpInsert, Cy: 0, Cx: 2, Text: []rune{'X'}}
按下 Ctrl+Z 時,Undo 不需要知道檔案之前的整行長怎樣,只要將 op 傳給 inverse(op),轉換為 OpDelete 並在 (0, 2) 執行刪除,文字與游標就會精準回到修改前的狀態。
在這個專案中,我們實作了以下 CLI 開發的基礎功能:
rowoff/coloff),讓終端機只繪製螢幕看得見的範圍。[]rune 處理字元增刪,採用寫入臨時檔再更名的安全存檔機制,並以可逆操作實現 Undo/Redo。串連這些功能後,就能組裝出一個具備基礎顯示、游標移動、動態編輯與安全存檔的終端機文字編輯器。
編輯器已具備輸入、繪製、載入與編輯的完整流程,但每次都靠手動操作驗證並不可靠。下一篇回到 CLI 的測試設計:先用單元測試驗證 Command 的輸出、Exit Code 與輸入組合,再以實際啟動 Process 的方式驗證完整行為。