用 Go 写一个你自己的 Nano

我编写了一个简单的面向 terminal 的文本编辑器.

设计

和 MVC 设计类似,一个编辑器含有三个独立的模块:

  1. controller:处理键盘输入
  2. model:存储文档并运行主要逻辑
  3. view:渲染编辑器界面和文档

Overview

你可以认为编辑器一直处于无限的循环中。首先我们清空屏幕并把文档内容渲染到 stdout。渲染的内容由你 cursor 的位置和 terminal 大小决定。然后我们一直聆听 stdin 的键盘输入,并以此进行对应操作。

for {
    editor.display.DrawScreen(e.buf, e.cursorX, e.cursorY)
    if editor.process() {
        break
    }
}

如何处理输入

我们监听 stdin 的方式是使用 rune。常规的按键只占用一个 rune。非常规的按键,又被称作 escape key,会占用多个 rune。Escape key 通常以 rune 27 为开头。所以当我们收到的第一个 rune 值为 27 时,我们就继续读取接下来的 rune 来判断这个 escape key。比如,当 arrow up (上方向)键按下的时候,它会发出 3 个 rune。我们需要全部读取这 3 个 rune 才知道这是一个 arrow up 的动作。通过读取第 3 个 rune 的值为 65,我们确定了这个按键。一旦我们了解了用户按下什么键,我们就可以把常规按键放入文档中,或者跟据非常规按键,来执行特殊动作,比如移动 cursor 或者关闭我们的编辑器。

if r == 27 {
    if e.reader.Buffered() == 0 {
		return ESCAPE
    }
    for i := 0; i < 3; i++ {
        r, _, err = e.reader.ReadRune()
        if err != nil {
            return UNKNOWN
        }
        if i == 1 {
            switch r {
            case 65: return ARROW_UP
            case 66: return ARROW_DOWN
            case 67: return ARROW_RIGHT
            case 68: return ARROW_LEFT
            }
        }
    }
}

如何存储文档

我们需要一些办法来存储文字。有许多成熟的数据结构,比如 ring buffer 或者 line of strings。考虑到现代硬件极强的运算能力,这里我使用了一个简化(粗糙)的方式,一个巨大的 string 来存储文档。接下来我会展开说明如何实现各种对于文档的操作。

插入一个文字

当用户在当前 cursor 下输入一个文字,这个文字会被插入到现在的位置。因为 cursor 的坐标是 2D,所以我们需要先把它转换为 1D 的 string index,然后就可以直接插入。

func (b *Buffer) getIdx(x, y int) int {
    idx := 0
    for idx, _ = range b.txt {
        if x == 0 && y == 0 {
            break
        }
        if x > 0 && b.txt[idx] == rune('\n') {
            x--
            continue
        }
        if x == 0 {
            y--
        }
        if b.txt[idx] == rune('\n') {
            break
        }
    }
    return idx
}

插入 Newline

和插入文字类似,只是内容变成了 \n

插入 Tab

Tab 比较难处理因为只基于 cursor 很难决定它的位置。一种办法是把 Tab 转换成空格来存,但这会使得以来 Tab 的文件无法使用,比如 Makefile,Fortran 或者 Assemble。另一种办法是将 Tab 存为 \t 然后禁止 cursor 在 Tab 渲染出来的空格中间移动。

删除文字

和插入类似,我们可以直接 shrink 这个 string 来去掉想删除的文字。我们不需要担心 Newline 或者 Tab,因为它们被存成了一个常规的字符。

移动 Cursor

为了让我们可以插入和删除,Cursor 的位置一直被记录着。值得注意的是,我们需要修复 cursor 的位置如果它移出了编辑器界面的范围。比如用户试图把 cursor 移到一行的最右边,实际上 cursor 会被重置会下一行的最左边。

另一个需要的注意的事是,当我们删除一行结尾的 line breaker 或者插入 Newline 时,我们需要移动 cusor 到对应的之前行的最右边或者下一行的最左边。

另外我们需要支持滚动,让 cusor 可以自由的在整个文档里移动,而不受限于 terminal 大小。

如何渲染文档

编辑器的内容是由一列 rune 转换渲染出来的。我们把特殊的字符,比如 Tab 和 Newline 变成对应的空格和换行。因为 terminal 大小往往无法显示全部的文档,我们还需要根据 cursor 在文档中的位置,决定哪一部分内容被渲染到界面上。同时当用户把 cursor 移动到未被显示出来的内容时,我们需要重新计算可以被显示的文档部分。

滚动

我们记录 offsetXoffsetY 的位置。那么当前可以被渲染的区域就是,

Rendering View size = [offsetX, offsetX + viewX, offsetY, offsetY + viewY]

viewXviewY 是 terminal 的长宽。如果 cursor 移出了这个区域,我们需要更新 offsetXoffsetY

func (d *Display) scroll(cursorX int, cursorY int) {
    if cursorX >= d.offsetX+d.viewX {
        d.offsetX = cursorX - d.viewX + 1
    } else if cursorX < d.offsetX {
        d.offsetX = cursorX
    }
    if cursorY >= d.offsetY+d.viewY {
        d.offsetY = cursorY - d.viewY + 1
    } else if cursorY < d.offsetY {
        d.offsetY = cursorY
    }
}

然后我们可以用 offsetX 来算出 viewX 数量的行去渲染。每一行, 我们只渲染 index 在 offsetY 之后的字符。

感悟和所学

  1. Go 原生通过 rune 支持 UTF8。消除了对于 CJK 字符的处理。
  2. Go 对于 Terminal 的操作会因为不同操作系统而有区别。如果使用 termbox 这样的第三方库会方便很多。
  3. 把 View 里的 2D 位置转换成 string 文档中的 index 是一个非常繁复的工序。