当有人问你:“你的光标在哪里?”时,你会怎么说? 可能会说类似“第 18 行”,或者如果你那天心情好,而且它是个位数,你也可以加上列:“第 18 行,第 5 列”。 行和列 —— 简单、容易。

文本编辑器(包括 Zed)也使用行和列来描述位置,但是——当我第一次探索 Zed 的代码库时,令我惊讶的是——Zed 中还有很多其他的坐标系统。 有偏移量、UTF-16 中的偏移量、显示点和锚点。

为了最终理解这些不同的文本坐标系统,以及何时使用哪个系统,我和 Zed 的两位联合创始人 Nathan 和 Antonio 进行了交谈,并请他们带我了解这一切,从 PointDisplayPointAnchor

配套视频

文本坐标系统

这篇文章附带一个 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));
}

请注意,这两行——52 ——被加在一起,但结果列为 10,这是 point_bcolumn 值。

文本点的数学运算——不像我想象的那么简单。

偏移量

偏移量是 Zed 中的另一种类型和文本坐标系。 它们直接。 Offset 是一个绝对数字,它表示文档中的位置,即从文档开头开始的字节数的单个计数。

文档的开头是 Offset::new(0)Hello WorldW 的位置是 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 拥有 OffsetUtf16PointUtf16 的原因:为了与语言服务器进行通信。例如,这里有一个方法,用于查找缓冲区中给定位置的定义

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 上关于 RopeSumTree 数据结构的文章。 现在,只需要知道我想表达的观点是:由于语言服务器,UTF-16 对 Zed 来说非常重要,以至于实现 RopeSumTree 已经索引了 UTF-16 的点和偏移量,从而产生了两个新的文本坐标系 - PointUtf16OffsetUtf16 - 并且可以非常快速地与 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

看看这个截图

Where's the cursor?
光标在哪里?

光标的位置是什么?仔细看。作为一个普通的 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 中)允许我们在“真实” PointDisplayPoint 之间进行转换

  • Point::new(0, 10) 显示在 DisplayPoint::new(1, 2)
  • Point::new(1, 11) 显示在 DisplayPoint::new(4, 1)

很棒,对吧?

在底层有很多东西在发生,我很想进一步研究,但是我们已经超时了,所以让我们继续下一个坐标系。 或者至少我曾经认为的坐标系,但事实证明不是:锚点。

锚点

在我与 Nathan 和 Antonio 的对话之前(您可以在这里观看配套视频),我就知道锚点 - 我在代码库中看到了 Anchor 类型和各种相关方法 - 并认为它们是表示文本文档中位置的另一种方式 - 另一种坐标系。

事实证明,这是一个稍微错误的假设。 锚点确实与文本文档中的位置相关,但与 PointsOffsetsDisplayPoint 非常不同。

假设您有一个像这样的文本文档

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 是“进入这个单调增长结构的锚点”。

但这里是最酷的部分:这不仅对协作有用!锚点也用于文本的后台处理。想想看:你想将一段文本发送到,例如,在后台运行的语言服务器。你创建两个锚点——选择的开始和结束——并启动一个后台进程,使用这两个锚点将文本发送到语言服务器。与此同时,用户可以继续键入和更改文本,因为这两个锚点将永远有效,因为它们被锚定到不可变文本中的某个位置。

就这样——PointOffset、UTF-16 对应项、DisplayPointAnchor——谁能想到我们会从行和列到 Lamport 时钟呢?