编辑器的第一块砖:文本、快照和撤销
接着上一篇,砚决定先做一个不依赖界面的 Go 内核。
真正开始写时,第一个问题很朴素:文件读进来以后,放在哪儿?用一个大字符串当然能显示内容,但一旦加上插入、删除、多光标和撤销,数据怎么组织就开始影响后面所有功能。
rope 先做简单的一版
砚现在的底层还是 M1a:平铺的不可变叶子数组,加前缀摘要。还没有换成设计里下一步的持久化 B+ 树。
文本按目标上限 2 KiB 分块,切点避开 UTF-8 字符中间。每个块对应字节数、换行等摘要,前缀摘要帮助定位字节和行。
text
Snapshot A
├─ 叶子 0 ─┐
├─ 叶子 1 │ 未修改的叶子可以复用
└─ 叶子 2 ─┤
│
Snapshot B │
├─ 叶子 0 ─┘
├─ 新叶子 1
└─ 叶子 2
一次编辑产生新结构,旧快照保持原样。撤销可以保存快照,后台任务也能读一份稳定内容。
这里不能把它介绍成已经有完整树结构的复杂度。当前实现虽然复用未改的字节块,仍然会重建叶子引用数组,并更新修改点之后的前缀摘要。它是一个先把接口和行为做稳的版本,大文件编辑能力不能只靠"用了 rope"三个字来保证。
把"第几个字符"拆开
编辑器里至少有三种经常被混用的数量:UTF-8 字节、Unicode 字符、屏幕上占的宽度。再考虑组合字符,"用户按一次退格删掉的东西"也不一定等于一个 rune。
砚在内核里统一使用 UTF-8 字节偏移。移动和删除去找字素边界,绘制时再换算显示宽度。
这个约定的价值会在后面越来越明显:搜索匹配、语法 token、多光标选区、diff 跳转如果各自用不同的列单位,中文附近很容易出现看着只差一点、实际改错位置的问题。
一次编辑先描述成变更
代码里的 Change 很小:
go
type Change struct {
Start, End int
Text string
}
含义是用 Text 替换旧快照的 [Start, End)。一组变更组成 ChangeSet,所有坐标都相对同一个旧快照。
比如多光标同时插入文字,如果按从前往后的顺序直接改文本,后面的偏移就会被前面的插入推走。砚按从后往前的顺序应用,保持原坐标有效;再用位置映射把选区移到新快照。
以后新增编辑命令,可以先问两个问题:它产出什么变更?这些变更应该算几个撤销步骤?比起每个命令自己碰底层文本,这样更容易把行为统一起来。
撤销还得知道"这一组动作"
保存快照解决了"回到哪个版本",但没有自动解决"按一次撤销回退多少"。
连续键入、命令修改、粘贴、输入法提交,需要有合适的分组边界。中文输入法一次提交一句话,用户通常希望一次撤销就退掉这次提交,而不是逐个字符退。
砚的编辑事务带来源信息,历史同时保存文本和选区。重载磁盘内容、命令还原等行为也尽量进入可撤销的编辑路径。这样从外面发生的变化,不会突然让撤销记录失去上下文。
保存要留下原文件的习惯
文本层保存采用同目录临时文件、写入、fsync、关闭、rename 的顺序,并保留原文件权限。UTF-8 BOM 和原来的换行内容也跟着保存,不为了方便内部处理就把整个文件统一改成另一种格式。
但原子替换解决的是写入过程的一类问题。另一个程序在我编辑期间修改了同一文件,仍然可能被覆盖。这个问题后来在自动保存审查里单独补了保护,不能把"原子保存"理解成所有数据冲突都解决了。
当前文件分档也相当保守:8 MiB 以上只读,超过 256 MiB 拒绝打开,非 UTF-8 内容只读。设计提过 GB18030 等能力,源码里不能据此宣称已支持完整转码和回写。
这块看起来离界面最远,却决定了编辑器能不能让人放心输入。底座稳一点,后面的搜索、Git 和窗口层才不用各自发明一遍"如何改文字"。