编辑器的第一块砖:文本、快照和撤销

编辑器的第一块砖:文本、快照和撤销

接着上一篇,砚决定先做一个不依赖界面的 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 和窗口层才不用各自发明一遍"如何改文字"。


相关推荐
我不会起名字3221 天前
Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现
redis·go·延迟队列·zset·订单
FungLeo1 天前
成为全栈·Go 后端篇·从 Node SQLite 到 PostgreSQL:把迁移做成独立交付物
postgresql·golang·sqlite·go·数据库迁移·成为全栈
皓月盈江1 天前
GO使用Gin框架实现模板定义、解析与渲染
linux·golang·go·gin·定义模板·解析模板·渲染模板
我不会起名字3221 天前
Redis 从 0 到 1(十九):JWT 还是 Redis Session?登录态存储的 Go 实现
redis·go·jwt·session·登录态
hourinX1 天前
一张只有两种颜色的头像,为什么还要用 NRGBA?—— `gdenticon` 重构日志(四)
go
我不会起名字3221 天前
Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测
redis·go·uv·布隆过滤器·hyperloglog
FungLeo2 天前
成为全栈·Go 后端篇·换后端不改前端:四个客户端如何做兼容回归
flutter·golang·go·成为全栈·兼容测试·taro 小程序
我不会起名字3222 天前
Redis 从 0 到 1(二十一):P99 涨到 80ms,慢查询日志 5 步定位法 + Go 采集器
redis·go·慢查询·排查·slowlog
我不会起名字3222 天前
Redis 从 0 到 1(十八):List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操
redis·消息队列·go·stream·pub/sub
孔令飞2 天前
AI 岗位类别、要求、技能及学习路线
云原生·go·ai编程