Skip to content

【译】 第四章:文本阅读器

April 30, 2022 | 06:45 AM

本文翻译自:Hecto, Chapter 4: A text viewer – Philipp Flenker – Engineering Manager,封面图也来源自此。

让我们在这章中看看能不能把 hecto 变成一个文本阅读器。

行阅读器

我们还需要一些数据结构:Document 表示用户现在正在编辑的文档,和 Row 表示该文档中相应的行。

到 GitHub 看这一步。

在这次改动中,我们在代码中引入了两个新概念:第一,使用了一个叫作 Vector 的数据结构,它会存储行。 Vector 是一个动态结构 — 当我们增加或者删除元素时,它可以在运行时扩充和缩小。语法 Vec<Row> 表示 Vector 会存储类型为 Row 的元素。另一个新概念是这行:

#[derive(Default)]

它表示 Rust 编译器应该派生 default 的实现。default 返回每个字段都被初始化为缺省值的结构体,这是编译器能为我们做的事。有了派生,我们不必自己实现 default

让我们看看用派生是否还能简化现在的代码。

到 GitHub 看这一步。

通过为 Position 派生 default ,我们移除了重复初始化光标位置为 0,0 的代码。如果将来,可能我们决定以另外一种不同的方式初始化 Position ,那么就自己实现 default,而不需要修改其他的代码。 我们不能为其它非常复杂的结构体派生 default。因为 Rust 不能估计结构体所有字段的默认值。

现在,让我们用一些文本填充 Document 结构体。我们还不必考虑从文件中读取内容的问题。相反,我们先在其中硬编码一个 “Hello, World” 字符串。

到 GitHub 看这一步。

你可能会好奇 Row impl 块中的 From<&str> 。我们现在不仅实现了一个 from 函数,还借此给 Row 实现了 From trait。在本教程的内容中我们不需要这个概念,但是实现 trait 能让我们在某种方式下使用某个函数功能。一会儿,我们会更详细地处理 trait ,但是现在如果感兴趣的话,查看文档的这一部分 — 实现 from 的同时也自动实现了 into

一会儿,我们再实现从文件中打开 Document 的方法。那时,我们将在初始化 editor 时再次使用 default。但是,现在让我们把目光放到展示硬编码值(字符串)上来。

到 GitHub 看这一步。

让我们从 Row 开始解释这次改动。我们添加了一个叫作 render 的方法。我们给它起名render ,是因为最后它不仅返回一个子字符串,还会负责更多任务。 我们的 render 方法非常人性化,因为它对模拟输入进行了规范化处理 — 基本上,它返回它所能产生的最大子字符串。我们也照例使用了 unwrap_or_default ,尽管这里其实没必要用,因为我们事先处理了 startend 参数。最后一行尝试创建字符串的子串,否则把它转换为字符串默认值("")。(在 Rust 中,Stringstr 有一些不同之处。我们稍后再来讨论。)

Document 中,我们添加了一个方法来检索在某个索引下的 Row。我们使用 Vector 的 get 方法实现,它有我们需要的函数签名:如果索引越界,返回 None ;否则返回所拥有的 Row

让我们把目光转移到 Editor 上来。在 draw_rows 中,我们首先重命名变量 rowterminal_row 以防止与现在从 Document 获取的 row 混淆。然后,我们检索 row 并展示它。

这的概念是 Row 确保返回给你一个可以被(正常)显示的子字符串,Editor 确保其匹配终端维度。

然而,我们的欢迎信息仍然在显示。当用户打开一个文件时,我们不想还显示欢迎信息,所以,让我们给 Document 添加一个 is_empty 方法并在 draw_rows 中对其进行检查。

到 GitHub 看这一步。

你应该能发现这个欢迎信息不再出现在屏幕的中间了。下面,让我们允许用户打开并显示真实文件。我们从修改 Document 开始:

到 GitHub 看这一步。

我们在启动时使用了一个默认的 Document,还添加了一个新方法 open,它尝试打开一个文件,并在失败时返回一个错误。

open 读取文本行到我们的 Document 结构体中。在我们的代码中不太明显,但是 rows 中的每行不会包含行结尾结束符 \n 或者 \r\n ,因为 Rust 的 line() 方法会帮我们删掉。这是有道理的:我们已经在处理没有结束符的行了,所以就不想再处理文件中的原始行了。

现在,让我们真正用 open 打开一个由命令行传给 hecto 的文件:

到 GitHub 看这一步。

通过运行 cargo run 对比 cargo run Cargo.toml 来试试吧!

这里有一些东西要注意。第一,我们可以使用 if..else 作为一个语句 — 在该情况下,它表示 if 语句中的任意一个代码块的结果被绑定到 document。为了达到该目的,我们必须要省略if 中每个代码块最后一行的 ; ,但是在最后的 } 后添加 ;,这样确保 document不会没有定义。

因为我们在 Document 上实现了 default,所以我们可以在这里使用 unwrap_or_default 。如果打开时发生错误,它就返回一个默认的 document,但是错误会被丢弃(我们以后会改进)。

只要我们接收超过一个 arg 就调用 Document::open()args 是一个 Vector ,它包含传递给我们程序的命令行参数。根据惯例,args[0] 往往代表我们程序的名字,所以 args[1] 包含我们需要的参数 — 我们想用 hecto (filename) 来打开一个文件。当你在开发程序的时候,你可以通过运行 cargo run (filename) 来传递一个文件名到程序中。比如,现在当你运行 cargo run Cargo.toml 的时候,应该能看见屏幕上的几行文字。

滚动

下面,我们想让用户滚动浏览整个文件,而不仅仅是文件的前几行。让我们添加 offset 到编辑器的状态中,它会记录用户当前滚动到文件的哪一行。为此,我们将再次使用 Position 结构体。

到 GitHub 看这一步。

我们用缺省值初始化它,这意味着我们会默认滚动到文件的左上角。

现在让 draw_row() 根据 offset.x 的值展示文件每行的正确范围,让 draw_rows 根据 offset.y 的值显示正确的行数范围。

到 GitHub 看这一步。

(译者注:131 行代码如下:)

if let Some(row) = self.document.row(terminal_row as usize + self.offset.y) {

我们加 offsetstartend 中,从而得到正在打印的字符串的切片。我们还确保能够处理字符串不足以填满屏幕的情况。如果当前行在当前屏幕的左边结束了(我们在一个长度比较长的行中向右滚动,这种情况就会发生) ,Rowrender 方法将会返回一个空字符串。那我们在哪设定 offset 的值呢?我们的策略是检查光标是否移出了可视窗口,如果移出去了,则调整 offset 让光标刚好在可视窗口内。我们会把这个逻辑放到一个叫作 scroll 的方法中,并在处理完按键输入后立刻调用它。

到 GitHub 看这一步。

为了滚动(编辑器),我们需要知道终端的长和宽以及现在的位置,并且还要修改self.offset 的值。如果光标向左或是向上移动,我们会设定 offsetdocument 中的新位置。如果光标移到了很右的地方,我们用现在位置减去当前的偏移量(指 width 或 height)来计算新的 offset

现在,让光标能移动到屏幕的底部(但不能超过文件的底部)。我们稍后解决向右滚动的问题。

到 GitHub 看这一步。

现在当你运行 cargo run src/editor.rs 时,应该能滚动浏览整个文件了。最后一行的处理还有点奇怪,因为我们能把光标发放在那,但(文字)并没有在那渲染。在本章下面我们添加了状态栏后,这个问题就会被修复。如果你尝试向上滚动,可能注意到光标没有被正确放置。这是因为 editor 状态中的 Position 不再表示光标在屏幕上的位置,而是表示光标在文本文件中的位置,但是我们仍然传给它 cursor_position。为了在屏幕上正确放置光标,现在我们必须将 document 中的 position 减去 offset

到 GitHub 看这一步。

现在让我们修复水平滚动的问题。这里少的是我们还没让光标能向屏幕右侧移动。与向下滚动那样,对称地修改相应的代码:

到 GitHub 看这一步。

我们要做的是修改 move_cursor 中用到的 width

现在可以水平滚动了。为了避免你疑惑,我想说只要你实现了 len 函数,再实现 is_empty 是一个最佳实践。我们现在不会使用 is_empty,但是 Clippy 会指出这个问题(译者注:如下代码所示,Clippy 会警告没有实现 is_empty方法),好在我们可以很容易实现。处理滚动操作的代码仍然有一个小 bug,我们会在几个改动后修复。话说,你能发现这个 bug 吗?

warning: struct `Row` has a public `len` method, but no `is_empty` method

将光标锁定在行尾

现在 cursor_position 表示光标在文件中的位置,而不是在屏幕中的位置。所以我们下面几步的目标是限制 cursor_position 的值只能指向文件中的有效位置。但例外的是,我们允许光标指向超过行末或超过文件末尾的一个字符,这样用户就可以在行末添加新的字符,文件末尾的新行也可以轻松添加。

我们已经能防止用户向右滚动太远或者向下滚动太远。然而,用户仍然能够移动光标超过一行的末尾。他们能通过先移动光标到一个长行的末尾,然后移动光标到下一个较短的行来做到这点。cursor_position.y 的值不会改变,所以光标不会移动到现在所在行的右侧。

让我们向 move_cursor() 添加一些代码, 如果 cursor_position 最后超过了所在行的末尾,就会纠正它的位置。

到 GitHub 看这一步。

我们需要再次设定 width 的值,因为在处理按键的过程中 row 发生了改变。然后,设定新的 x 值,确保 x 不会超过现在行的宽度。

PAGE UPPAGE DOWN 来滚动

既然,我们已经能够滚动屏幕了,就让 PAGE UPPAGE DOWN 按键向上和向下滚动整页吧,别再是整个文档了。

到 GitHub 看这一步。

为什么我们能避免没必要的饱和算数运算呢?举个例子,yheight 拥有相同的类型,如果 y.saturating_add(terminal_height) 小于 height ,那么 y + terminal_height 也会小于 height

如果尝试运行该程序,我们能发现最后一行仍然有问题。按下 PAGE DOWN 光标没有移动到下个屏幕,而是停在了底部的空行。我们会在本章的最后修复这个问题。

但是在修复之前,让我们完成文件中的光标导航功能。

在行首向左移动

我们想要用户在行首按下 <- 后,光标移到前一行的尾部。

我们在向上一行移动时,确保光标不在在第一行。我们也不必再使用 saturating_sub 了,因为检查了要减的值是否大于 0 。

在行尾向右移动

相似地,让用户在行尾按下 -> 后,光标移到下一行的开头。

到 GitHub 看这一步。

我们需要在向下移动一行之前,确保光标不在文件的最后一行。我们也可以在这去掉 saturating_addheighty 是同一类型,所以如果 y 小于 height ,那么我们有足够的空间来加 1 (译者注:x 同理)。

修复滚动中的问题

现在,让我们专注于我上面提到过的 bug。为了复现该 bug,保存下面的内容到一个文本文件中,然后用 hecto 打开。

aaa
äää
y̆y̆y̆
❤❤❤

当你在该文件中滚动浏览时,你会发现只有第一行正确滚动到了右侧,其它所有行都能滚动超过行尾的位置。本教程中第一步是观察每次按下按键返回给我们的字节。我们发现德国变音符号,比如 ä 返回多个字节,这就是导致上面 bug 的原因(译者注:汉字也是如此,读者可以尝试一下)。

让我们更仔细的观察这个行为。不要担心,我们不会再修改代码为观察按键按下,相反我们会跳转到 Rust playground 中并复制下面的代码:

 fn main() {
    dbg!("aaa".to_string().len());
    dbg!("äää".to_string().len());
    dbg!("y̆y̆y̆".to_string().len());
    dbg!("❤❤❤".to_string().len());
}

dbg! 是一个宏,它对快速并且令人讨厌的调试很有帮助。它打印当前输入的值,以及更多的提示信息。下面是该代码的返回结果:

[src/main.rs:2] "aaa".to_string().len() = 3
[src/main.rs:3] "äää".to_string().len() = 6
[src/main.rs:4] "y̆y̆y̆".to_string().len() = 9
[src/main.rs:5] "❤❤❤".to_string().len() = 9

现在我们发现字符串的长度比我们想象的要大,因为某些字符占用超过一个字节,或者是多个字符的结合。比如,女科学家 emoji (👩‍🔬) 是女性 emoji 👩 和 显微镜 emoji 🔬 的结合体。错误处理这些 emoji 会导致一些有趣的结果

译者注:如果想要了解更多关于 emoji 的内容,可查看该视频

所以,对于我们来说,一行的长度是多少呢?从根本上来说,一个元素表示鼠标能够移动的最小单位。它被称为字位(grapheme)。Rust 不像其它一些程序设计语言,默认不支持字位。这意味着要不我们自己来编程实现,要不就用其它 crate 来帮我们实现。因为我不想重新造轮子,为此就使用其它 crate 吧。

到 GitHub 看这一步。

我们引入了两处改动。在这两处改动中,我们都在完整字符串的切片上(由 [..] 表示)执行 graphemes() 然后使用其返回的迭代器。在 len() 函数中,我们在迭代器上调用 count() ,来告诉我们共有多少字形。在 render 函数中,我们现在开始不用内建的方法来创建我们自己的字符串。为此,我们跳过了屏幕左边所有的字形,然后只取后面的 end-start 个字形(行的可视部分)。这些字形稍后被追加到返回值中。

尽管这样是可以运行的,但是性能不是最优的。count 实际上遍历了整个迭代器,然后返回迭代器中元素数量。这意味着,对于每个可见行来说,我们在重复计算整行的长度。我们还是自己记录长度吧。

到 GitHub 看这一步。

现在,我们只要记得当行改动时调用 update_len就好。滚动现在可以正常工作了 — 或者真的可以了吗?事实证明,我们必须再经历一次折磨,才能让把这个功能做好。

绘制 Tab 键

译者注:该小节中讨论的问题可能已经被 unicode-segmentation 修复,所以如果你没有遇到下面的问题,可以选择跳过不做修改。

如果你尝试打开一个有制表符(Tab)的文件,会注意到 制表符占据了 8 列左右的宽度。你可能知道,(编程界)有一场关于用 Tab 键还是用空格来缩进的旷日持久的争论。坦白讲,我不在乎。我一直使用一个很高级的编辑器,它可以处理你甩给它的任意缩进类型。如果我被迫站队的话,我还是会站空格键。因为我发现 Tab 键的优点不能让我信服 — 但这是另外一回事了。不过,在本教程中,重要的是我们可以用一个空格来替换制表符。这对我们的目的来说已经足够。因为在 Rust 生态中,你很少会遇到制表符。

现在,让我们用空格替换制表符。

到 GitHub 看这一步。

好了,我们终于处理了所有的极端情况。现在让我们解决最后被一个问题,它卡着我们很久了:修复最后一行。

状态栏

在我们实现文本编辑功能之前,要添加的最后一个功能是状态栏。它会显示提示信息,比如文件名、文件的行数和当前所在行。稍后,我们会添加一个标记来提示文件在上次保存过后,是否被修改过了;还会在实现语法高亮时,展示文件类型。

首先,我们会在屏幕底部留两行状态栏的空间,然后修复绘制最后一行时发生的问题。

到 GitHub 看这一步。

你现在应该能注意到底部两行被清掉了,并且 PAGE UPPAGE DOWN 也能如期进行;还要关注一下改动是怎样奏效的。

现在,我们的文本阅读器,包括滚动和光标移动,工作得很好。我们把显示状态栏的最后几行交给剩下的代码处理。

为了让状态栏更突出一些,我们会用不同颜色展示。因为有 termion 帮我们处理相应的转义序列,所以我们不需要手动输入。对应的转义序列是 m 命令(选择图形渲染,Select Graphic Rendition)。VT100 用户指南没有关于颜色的文档,所以我们查看关于 ANSI 转义代码的维基百科文章。它有一个大表,其中包含了你可以在多种终端上使用m 命令的所有不同参数码,还包含了拥有 8 种可用的前景/背景颜色的 ANSI 颜色表。我们会借助 termion 来设置 RGB 颜色。在当前终端不支持该颜色的情况下,它将退回到更简单的颜色。

注意:在某些终端上面,比如 Mac,termion 不能恰当展示颜色。就本教程而言,你可以使用 termion::style::Invert如果想了解更多信息,请查看这条 Github issue。

到 GitHub 看这一步。

我们已经开始用一些新函数来扩展终端了。它们分别设置和重置(终端)背景颜色。我们需要在使用 set_bg_color 后重置颜色,否则屏幕的其它部分也会被绘制成相同的颜色。在 editor 中使用这两个个功能,在状态栏所在的地方绘制出一行空格。我们还添加了一个函数来绘制状态栏下面的信息栏(message bar),但现在先让它空着。

下面我们想展示文件名。先修改 Document ,让它有一个可为空的文件名,然后在 open 中初始化。同时也准备让 Terminal 能设置和重置前景颜色。

到 GitHub 看这一步。

请注意我们不再用 String 作为文件名的类型了,而是用了 Option。它表明我们要么得到了文件名要么得到了 None,以防止没有设置文件名。

现在,我们已经准备好在状态栏中展示一些信息了。我们会展示最多 20 个字符的文件名,和文件的总行数。如果没有文件名,我们就展示 [No Name]

到 GitHub 看这一步。

我们确保当状态信息字符串在窗口中放不下时,切短一些。还要注意,我们仍然使用绘制空格到屏幕最右侧的代码,所以整个状态栏的背景都是白色的。

这里,我们使用了一个新的宏 format! 。它和 print!println! 差不多。不过不会真的向屏幕打印什么东西(译者注:只拼接字符串)。

现在,展示当前所在行数,然后把它放到状态栏的右边。

到 GitHub 看这一步。

当前行被存放在 cursor_position.y 中,因为位置是从 0 开始的,所以我们对其加 1。从要生成的空格数中减掉 line_indicator 的长度,然后添加它到最后的格式化字符串中。

状态信息

我们还要在状态栏下面添加一行。它会向用户展示信息,比如,在用户搜索时提示输入。我们会在一个叫 StatusMessage 的结构体中存储当前信息,它会被放在编辑器状态中。我们还会为该信息存储一个时间戳,从而可以在展示几秒后清除掉它。

到 GitHub 看这一步。

我们用按键绑定的帮助信息来初始化 status_message 。如果不能打开文件,就修改status_message 为一个错误。既然需要展示 status_message,我们可以修改 draw_message_bar() 函数。首先,我们用 Terminal::clear_current_line(); 清空信息栏。因为总是重写整行,所以我们不需要在绘制前清空状态栏。然后,确保 status_message 与屏幕宽度匹配,只要该信息展示时间不超过 5 秒就展示它。这表示即使我们不再展示它了,还会保留上一次的 status_message。这样还行,因为这个数据结构还是比较小的,并且不会随时间增长。

现在启动程序,你会发现底部的帮助信息。当你 5 秒后按下某个按键,它就会消失。记住,我们只在按键按下后刷新屏幕。

在下一章中,我们会把文本阅读器变成一个文本编辑器,让用户可以插入和删除字符,并且能保存改动到磁盘中。

结论

前几章所有的重构都是值得的,因为在本章中我们能轻松且毫不费力地扩展编辑器的功能。像上一章一样,我希望你能用充满自豪的眼光来看自己的文本阅读器。

话不多说,让我们在下一章中把注意力放到编辑文本上来。