当有人问你:“你的光标在哪里?”时,你会怎么说? 可能会说类似“第 18 行”,或者如果你那天心情好,而且它是个位数,你也可以加上列:“第 18 行,第 5 列”。 行和列 —— 简单、容易。
文本编辑器(包括 Zed)也使用行和列来描述位置,但是——当我第一次探索 Zed 的代码库时,令我惊讶的是——Zed 中还有很多其他的坐标系统。 有偏移量、UTF-16 中的偏移量、显示点和锚点。
为了最终理解这些不同的文本坐标系统,以及何时使用哪个系统,我和 Zed 的两位联合创始人 Nathan 和 Antonio 进行了交谈,并请他们带我了解这一切,从 Point
到 DisplayPoint
到 Anchor
。
配套视频
文本坐标系统
这篇文章附带一个 1 小时的配套视频,Thorsten、Nathan 和 Antonio 首先编写一些围绕 Point 和 Offset 的测试,然后冒险进入 DisplayPoint 和 Anchor 的领域。
点
首先,让我们讨论 zed 中最明显的文本坐标形式:Point
。 一个 Point
是 “文本缓冲区中的一个零索引点,由行和列组成”。 它看起来像这样
// crates/rope/src/point.rs
struct Point {
row: u32,
column: u32,
}
这并不奇怪。 行和列,主要内容。 这是我们测试中的一个片段,用于说明如何使用 Point
。
let last_selection_start = editor.selections.last::<Point>(cx).range().start;
assert_eq!(last_selection_start, Point::new(2, 0));
这里的断言试图确保选择从第 3 行(零索引!)的第 0 列开始。
Point
的一个方便属性是,它们可以很容易地表示沿行的导航。 将光标向下移动一行就像递增 row
值一样简单
let old_point = Point::new(18, 5);
let new_point = Point::new(point.row + 1, point.column);
通过简单的 +1
从第 18 行到第 19 行。 很好。 如果你想回去,就改成 -1
。 但是如果你想向左或向右导航怎么办?
这可能会变得棘手,因为不同的行可能具有不同的长度。 简单地添加或减去列可能会给你在文档中一个无效的位置。 事实证明,Point
的看似简单有点具有欺骗性——Point
需要仔细处理。
例如,在 Zed 中,Point
遵循 Nathan 称之为“打字机逻辑”:回车——本质上是添加一个新行——将列数重置为零,因为在打字机上,字车也会从下一行的开头开始。
为了说明这一点,这是一个在 Zed 的代码库中通过的测试。 请注意这些列
fn test_point_basics() {
let point_a = Point::new(5, 8);
let point_b = Point::new(2, 10);
let result = point_a + point_b;
assert_eq!(result, Point::new(7, 10));
}
请注意,这两行——5
和 2
——被加在一起,但结果列为 10
,这是 point_b
的 column
值。
文本点的数学运算——不像我想象的那么简单。
偏移量
偏移量是 Zed 中的另一种类型和文本坐标系。 它们很直接。 Offset
是一个绝对数字,它表示文档中的位置,即从文档开头开始的字节数的单个计数。
文档的开头是 Offset::new(0)
, Hello World
中 W
的位置是 Offset::new(6)
,文档中的最后一个字符是 Offset::new(document.len() - 1)
,假设每个字符都是一个字节。
在处理跨越多行文本的操作时,偏移量特别有用。 例如选择
let start = Offset::new(10);
let end = Offset::new(50);
let selection = Selection::new(start, end);
完全不用担心列的问题 —— 从这个字符到那个字符,包括换行符。 这很容易用 Offset
表示。
但是,同样,Offset
也存在一个小问题,因为单独的 Offset
是不够的。
UTF-16,什么?
在探索 Zed 的代码库时,我发现一个非常有趣的现象,你会发现 OffsetUtf16
的数量远远多于 Offset
。还有 PointUtf16
。我个人从未使用过 UTF-16,除非是在使用语言服务器和语言服务器协议时,该协议使用 UTF-16 编码来计算和描述文本文件的位置和偏移量。
事实证明,这正是 Zed 拥有 OffsetUtf16
和 PointUtf16
的原因:为了与语言服务器进行通信。例如,这里有一个方法,用于查找缓冲区中给定位置的定义
fn definition<T: ToPointUtf16>(
&self,
buffer: &Model<Buffer>,
position: T,
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<LocationLink>>> {
let position = position.to_point_utf16(buffer.read(cx));
self.definition_impl(buffer, position, cx)
}
该位置 - 一个实现了 ToPointUtf16
trait 的 T
- 在发送到语言服务器之前,被转换为 PointUtf16
。 在底层,这最终可能会调用我们 Rope
数据结构上的以下方法
// crates/rope/src/rope.rs
impl Rope {
fn point_to_point_utf16(&self, point: Point) -> PointUtf16 {
if point >= self.summary().lines {
return self.summary().lines_utf16();
}
let mut cursor = self.chunks.cursor::<(Point, PointUtf16)>();
cursor.seek(&point, Bias::Left, &());
let overshoot = point - cursor.start().0;
cursor.start().1
+ cursor.item().map_or(PointUtf16::zero(), |chunk| {
chunk.point_to_point_utf16(overshoot)
})
}
}
为了理解这里的每一行代码,我建议阅读 Zed Decoded 上关于 Rope
和 SumTree
数据结构的文章。 现在,只需要知道我想表达的观点是:由于语言服务器,UTF-16 对 Zed 来说非常重要,以至于实现 Rope
的 SumTree
已经索引了 UTF-16 的点和偏移量,从而产生了两个新的文本坐标系 - PointUtf16
和 OffsetUtf16
- 并且可以非常快速地与 UTF-16 进行相互转换。
DisplayPoints
如果我们攀登抽象阶梯,将偏移量、行和列抛在脑后,那么接下来就会遇到 DisplayPoint
。什么是 DisplayPoint
?
// crates/editor/src/display_map.rs
struct DisplayPoint(BlockPoint)
DisplayPoint
是围绕 BlockPoint
的一个 新类型。什么是 BlockPoint
?
// crates/editor/src/display_map/block_map.rs
struct BlockPoint(pub Point);
BlockPoint
是... Point
- 等等,什么?这是否意味着我们根本没有攀登抽象阶梯,而是在抽象仓鼠轮中转了一圈?
不完全是!DisplayPoint
确实是一个 Point
,是的,但是在这个上下文中 - 在 DisplayPoint
内部和 editor
crate 内部 - Point
的行和列具有不同的含义。它们并不指代磁盘上文本文件中的对应行和列,而是指您看到的行和列,即编辑器中显示的行和列。因此是 DisplayPoint
。
看看这个截图

光标的位置是什么?仔细看。作为一个普通的 Point
(从零开始索引!),它将是第 23 行,第 23 列。但是作为一个 DisplayPoint
,光标的位置是第 29 行,第 36 列!
那是因为 DisplayPoint
描述了 DisplayMap
上的一个位置(我们希望在 Zed Decoded 的未来剧集中介绍它),并考虑了以下因素
- 自动换行
- 折叠
- 内嵌提示
- 制表符
- 块 & 折痕
在该截图中,您可以看到第 6 行是自动换行的,并且占据了不止一行。 Point::MAX
的定义被折叠。 一个块显示一个诊断错误。 并且在光标所在的 zero()
方法中,光标的左侧有两个内嵌提示。
DisplayPoint
允许 Zed 考虑所有这些因素并准确地描述光标的位置 - 在换行、诊断、折叠、内嵌提示等之间。
这是一个修改后的版本,来自我发现的测试,它非常说明了 DisplayPoint
的作用
// Modified version of a test in crates/editor/src/display_map.rs
async fn test_zed_decoded(cx: &mut gpui::TestAppContext) {
// [... setup ...]
let font_size = px(12.0);
let wrap_width = Some(px(64.));
let text = "one two three four five\nsix seven eight";
let buffer = MultiBuffer::build_simple(text, cx);
let map = cx.new_model(|cx| {
DisplayMap::new(
buffer.clone(),
font("Helvetica"),
font_size,
wrap_width,
// [... other parameters ...]
)
});
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
// Given the above constraints — font_size, wrap_width, ... — the text above
// is displayed in 5 lines.
assert_eq!(
snapshot.text_chunks(DisplayRow(0)).collect::<String>(),
"one two \nthree four \nfive\nsix seven \neight"
);
// DisplayPoint(1, 0) is equivalent to Point(0, 8)
assert_eq!(
DisplayPoint::new(DisplayRow(1), 0).to_point(&snapshot),
Point::new(0, 8)
);
// DisplayPoint(1, 2) is equivalent to Point(0, 10)
assert_eq!(
DisplayPoint::new(DisplayRow(1), 2).to_point(&snapshot),
Point::new(0, 10)
);
// DisplayPoint(4, 1) is equivalent to Point(1, 11)
// (This is the "i" in "eight")
assert_eq!(
DisplayPoint::new(DisplayRow(4), 1).to_point(&snapshot),
Point::new(1, 11)
);
}
这是此测试的内容。给定文本...
one two three four five
six seven eight
...以及 12 像素的字体大小、64 像素的换行宽度、Helvetica 字体和一堆其他参数,文本将像这样显示
one two
three four
five
six seven
eight
并且 DisplayMap
(这里作为快照保存在本地变量 snapshot
中)允许我们在“真实” Point
和 DisplayPoint
之间进行转换
Point::new(0, 10)
显示在DisplayPoint::new(1, 2)
Point::new(1, 11)
显示在DisplayPoint::new(4, 1)
很棒,对吧?
在底层有很多东西在发生,我很想进一步研究,但是我们已经超时了,所以让我们继续下一个坐标系。 或者至少我曾经认为的坐标系,但事实证明不是:锚点。
锚点
在我与 Nathan 和 Antonio 的对话之前(您可以在这里观看配套视频),我就知道锚点 - 我在代码库中看到了 Anchor
类型和各种相关方法 - 并认为它们是表示文本文档中位置的另一种方式 - 另一种坐标系。
事实证明,这是一个稍微错误的假设。 锚点确实与文本文档中的位置相关,但与 Points
、Offsets
或 DisplayPoint
非常不同。
假设您有一个像这样的文本文档
Hello World!
一个 Anchor
(锚点) 允许你指向文档中给定字符的一侧——左侧或右侧。例如,你可以创建一个锚点,指向这里 W
的左侧。这与 Point::new(0, 6)
类似,但不完全相同:Point
描述了此文档版本中 W
的位置,而 Anchor
将会粘附在 W
的一侧,即使它被编辑了。
用 Nathan 的话说:
锚点是一个逻辑坐标。你可以在字符的右侧或字符的左侧创建一个锚点。然后,在未来的任何时间点,你都可以随时“兑换”这个锚点,并获得你本质上标记或锚定的字符的位置。即使在此期间发生了编辑,即使该代码已被删除,或者该字符已被删除,你仍然可以获得其墓碑的位置——如果它没有被删除,它将在哪里,或者如果删除被撤消,它将在哪里出现。
因此,如果我们要在上面的 W
的左侧附加一个锚点,并且之后文本文件被编辑成这样:
Hello and good day to you, World!
我们仍然可以取出我们的锚点并“兑换”它,将其转换为 W
现在所在的实际 Point
。
对于协作文本编辑器来说,这完全有意义:如果你的光标位于 W
上,并且有人过来编辑了它左侧的文本,你希望你的光标仍然停留在 W
上,而不是光标下面的文本发生变化。
如果你查看 Anchor
的定义,你可以看到它与 Zed 的协作特性和 CRDTs 的紧密联系。
// crates/text/src/anchor.rs, slightly simplified
/// A timestamped position in a buffer
struct Anchor {
timestamp: clock::Lamport,
/// The byte offset in the buffer
offset: usize,
/// Describes which character the anchor is biased towards
bias: Bias,
buffer_id: Option<BufferId>,
}
这里的 timestamp
是一个 Lamport 时间戳,一个*逻辑*时间戳。在 我们的对话 中,Antonio 说 timestamp
不是这里字段的一个好名字,它以前被称为 id
,这也是考虑它的一个更好的方式。 Nathan 解释说:
在 CRDT 中,或者至少在我们的 CRDT 实现中,每一段文本,无论是字符还是一大段粘贴的文本,还是其他插入的内容,都被视为一个不可变的块。这个不可变的块被赋予一个唯一的 ID,一个在集群中唯一的 ID。
上面的 timestamp: clock::Lamport
就是这个 ID。 Nathan 继续说:
[...] 基本上,这是一种获得唯一 ID 的方法,对吧?唯一性继承自副本 ID,然后每个副本当然可以随时通过递增其序列号来生成新的 Lamport 时间戳。这实际上是插入的 ID,用于原始的插入文本块。
所以,timestamp
是分配给不可变文本块的唯一 ID。offset
然后描述了 Anchor
在这段不会改变的文本中的位置,因为,再次强调,它是不可变的。 Nathan 谈到不可变性:
一旦我们插入它,它就是不可变的。如果你删除它的部分,我们可能会隐藏它们,为它们立墓碑,但它们仍然在那里。这就是我们实现协作的方式。整个事情是单调递增的。它只是随着时间的推移积累数据。正因为如此,我才能够引用插入 ID,无论什么,偏移量,无论什么。现在有很多索引和奇特之处来弄清楚它现在到底在哪里。但这至少是我们能够引用的一些稳定的东西。这就是我们选择锚定它的原因。
用 Nathan 的话说,Anchor
是“进入这个单调增长结构的锚点”。
但这里是最酷的部分:这不仅对协作有用!锚点也用于文本的后台处理。想想看:你想将一段文本发送到,例如,在后台运行的语言服务器。你创建两个锚点——选择的开始和结束——并启动一个后台进程,使用这两个锚点将文本发送到语言服务器。与此同时,用户可以继续键入和更改文本,因为这两个锚点将永远有效,因为它们被锚定到不可变文本中的某个位置。
就这样——Point
、Offset
、UTF-16 对应项、DisplayPoint
、Anchor
——谁能想到我们会从行和列到 Lamport 时钟呢?