
本文翻译自:Hecto, Chapter 4: A text viewer – Philipp Flenker – Engineering Manager,封面图也来源自此。
让我们在这章中看看能不能把 hecto 变成一个文本阅读器。
行阅读器
我们还需要一些数据结构:Document 表示用户现在正在编辑的文档,和 Row 表示该文档中相应的行。

在这次改动中,我们在代码中引入了两个新概念:第一,使用了一个叫作 Vector 的数据结构,它会存储行。 Vector 是一个动态结构 — 当我们增加或者删除元素时,它可以在运行时扩充和缩小。语法 Vec<Row> 表示 Vector 会存储类型为 Row 的元素。另一个新概念是这行:
#[derive(Default)]
它表示 Rust 编译器应该派生 default 的实现。default 返回每个字段都被初始化为缺省值的结构体,这是编译器能为我们做的事。有了派生,我们不必自己实现 default。
让我们看看用派生是否还能简化现在的代码。

通过为 Position 派生 default ,我们移除了重复初始化光标位置为 0,0 的代码。如果将来,可能我们决定以另外一种不同的方式初始化 Position ,那么就自己实现 default,而不需要修改其他的代码。
我们不能为其它非常复杂的结构体派生 default。因为 Rust 不能估计结构体所有字段的默认值。
现在,让我们用一些文本填充 Document 结构体。我们还不必考虑从文件中读取内容的问题。相反,我们先在其中硬编码一个 “Hello, World” 字符串。

你可能会好奇 Row impl 块中的 From<&str> 。我们现在不仅实现了一个 from 函数,还借此给 Row 实现了 From trait。在本教程的内容中我们不需要这个概念,但是实现 trait 能让我们在某种方式下使用某个函数功能。一会儿,我们会更详细地处理 trait ,但是现在如果感兴趣的话,查看文档的这一部分 — 实现 from 的同时也自动实现了 into。
一会儿,我们再实现从文件中打开 Document 的方法。那时,我们将在初始化 editor 时再次使用 default。但是,现在让我们把目光放到展示硬编码值(字符串)上来。

让我们从 Row 开始解释这次改动。我们添加了一个叫作 render 的方法。我们给它起名render ,是因为最后它不仅返回一个子字符串,还会负责更多任务。 我们的 render 方法非常人性化,因为它对模拟输入进行了规范化处理 — 基本上,它返回它所能产生的最大子字符串。我们也照例使用了 unwrap_or_default ,尽管这里其实没必要用,因为我们事先处理了 start 和 end 参数。最后一行尝试创建字符串的子串,否则把它转换为字符串默认值("")。(在 Rust 中,String 和 str 有一些不同之处。我们稍后再来讨论。)
在 Document 中,我们添加了一个方法来检索在某个索引下的 Row。我们使用 Vector 的 get 方法实现,它有我们需要的函数签名:如果索引越界,返回 None ;否则返回所拥有的 Row。
让我们把目光转移到 Editor 上来。在 draw_rows 中,我们首先重命名变量 row 为 terminal_row 以防止与现在从 Document 获取的 row 混淆。然后,我们检索 row 并展示它。
这的概念是 Row 确保返回给你一个可以被(正常)显示的子字符串,Editor 确保其匹配终端维度。
然而,我们的欢迎信息仍然在显示。当用户打开一个文件时,我们不想还显示欢迎信息,所以,让我们给 Document 添加一个 is_empty 方法并在 draw_rows 中对其进行检查。

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

我们在启动时使用了一个默认的 Document,还添加了一个新方法 open,它尝试打开一个文件,并在失败时返回一个错误。
open 读取文本行到我们的 Document 结构体中。在我们的代码中不太明显,但是 rows 中的每行不会包含行结尾结束符 \n 或者 \r\n ,因为 Rust 的 line() 方法会帮我们删掉。这是有道理的:我们已经在处理没有结束符的行了,所以就不想再处理文件中的原始行了。
现在,让我们真正用 open 打开一个由命令行传给 hecto 的文件:

通过运行 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 结构体。

我们用缺省值初始化它,这意味着我们会默认滚动到文件的左上角。
现在让 draw_row() 根据 offset.x 的值展示文件每行的正确范围,让 draw_rows 根据 offset.y 的值显示正确的行数范围。

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

为了滚动(编辑器),我们需要知道终端的长和宽以及现在的位置,并且还要修改self.offset 的值。如果光标向左或是向上移动,我们会设定 offset 为 document 中的新位置。如果光标移到了很右的地方,我们用现在位置减去当前的偏移量(指 width 或 height)来计算新的 offset。
现在,让光标能移动到屏幕的底部(但不能超过文件的底部)。我们稍后解决向右滚动的问题。

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

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

我们要做的是修改 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 最后超过了所在行的末尾,就会纠正它的位置。

我们需要再次设定 width 的值,因为在处理按键的过程中 row 发生了改变。然后,设定新的 x 值,确保 x 不会超过现在行的宽度。
用 PAGE UP 和 PAGE DOWN 来滚动
既然,我们已经能够滚动屏幕了,就让 PAGE UP 和 PAGE DOWN 按键向上和向下滚动整页吧,别再是整个文档了。

为什么我们能避免没必要的饱和算数运算呢?举个例子,y 和 height 拥有相同的类型,如果 y.saturating_add(terminal_height) 小于 height ,那么 y + terminal_height 也会小于 height 。
如果尝试运行该程序,我们能发现最后一行仍然有问题。按下 PAGE DOWN 光标没有移动到下个屏幕,而是停在了底部的空行。我们会在本章的最后修复这个问题。
但是在修复之前,让我们完成文件中的光标导航功能。
在行首向左移动
我们想要用户在行首按下 <- 后,光标移到前一行的尾部。

我们在向上一行移动时,确保光标不在在第一行。我们也不必再使用 saturating_sub 了,因为检查了要减的值是否大于 0 。
在行尾向右移动
相似地,让用户在行尾按下 -> 后,光标移到下一行的开头。

我们需要在向下移动一行之前,确保光标不在文件的最后一行。我们也可以在这去掉 saturating_add 。height 和 y 是同一类型,所以如果 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 吧。

我们引入了两处改动。在这两处改动中,我们都在完整字符串的切片上(由 [..] 表示)执行 graphemes() 然后使用其返回的迭代器。在 len() 函数中,我们在迭代器上调用 count() ,来告诉我们共有多少字形。在 render 函数中,我们现在开始不用内建的方法来创建我们自己的字符串。为此,我们跳过了屏幕左边所有的字形,然后只取后面的 end-start 个字形(行的可视部分)。这些字形稍后被追加到返回值中。
尽管这样是可以运行的,但是性能不是最优的。count 实际上遍历了整个迭代器,然后返回迭代器中元素数量。这意味着,对于每个可见行来说,我们在重复计算整行的长度。我们还是自己记录长度吧。

现在,我们只要记得当行改动时调用 update_len就好。滚动现在可以正常工作了 — 或者真的可以了吗?事实证明,我们必须再经历一次折磨,才能让把这个功能做好。
绘制 Tab 键
译者注:该小节中讨论的问题可能已经被
unicode-segmentation修复,所以如果你没有遇到下面的问题,可以选择跳过不做修改。
如果你尝试打开一个有制表符(Tab)的文件,会注意到 制表符占据了 8 列左右的宽度。你可能知道,(编程界)有一场关于用 Tab 键还是用空格来缩进的旷日持久的争论。坦白讲,我不在乎。我一直使用一个很高级的编辑器,它可以处理你甩给它的任意缩进类型。如果我被迫站队的话,我还是会站空格键。因为我发现 Tab 键的优点不能让我信服 — 但这是另外一回事了。不过,在本教程中,重要的是我们可以用一个空格来替换制表符。这对我们的目的来说已经足够。因为在 Rust 生态中,你很少会遇到制表符。
现在,让我们用空格替换制表符。

好了,我们终于处理了所有的极端情况。现在让我们解决最后被一个问题,它卡着我们很久了:修复最后一行。
状态栏
在我们实现文本编辑功能之前,要添加的最后一个功能是状态栏。它会显示提示信息,比如文件名、文件的行数和当前所在行。稍后,我们会添加一个标记来提示文件在上次保存过后,是否被修改过了;还会在实现语法高亮时,展示文件类型。
首先,我们会在屏幕底部留两行状态栏的空间,然后修复绘制最后一行时发生的问题。

你现在应该能注意到底部两行被清掉了,并且 PAGE UP 和 PAGE DOWN 也能如期进行;还要关注一下改动是怎样奏效的。
现在,我们的文本阅读器,包括滚动和光标移动,工作得很好。我们把显示状态栏的最后几行交给剩下的代码处理。
为了让状态栏更突出一些,我们会用不同颜色展示。因为有 termion 帮我们处理相应的转义序列,所以我们不需要手动输入。对应的转义序列是 m 命令(选择图形渲染,Select Graphic Rendition)。VT100 用户指南没有关于颜色的文档,所以我们查看关于 ANSI 转义代码的维基百科文章。它有一个大表,其中包含了你可以在多种终端上使用m 命令的所有不同参数码,还包含了拥有 8 种可用的前景/背景颜色的 ANSI 颜色表。我们会借助 termion 来设置 RGB 颜色。在当前终端不支持该颜色的情况下,它将退回到更简单的颜色。

注意:在某些终端上面,比如 Mac,
termion不能恰当展示颜色。就本教程而言,你可以使用termion::style::Invert。如果想了解更多信息,请查看这条 Github issue。
我们已经开始用一些新函数来扩展终端了。它们分别设置和重置(终端)背景颜色。我们需要在使用 set_bg_color 后重置颜色,否则屏幕的其它部分也会被绘制成相同的颜色。在 editor 中使用这两个个功能,在状态栏所在的地方绘制出一行空格。我们还添加了一个函数来绘制状态栏下面的信息栏(message bar),但现在先让它空着。
下面我们想展示文件名。先修改 Document ,让它有一个可为空的文件名,然后在 open 中初始化。同时也准备让 Terminal 能设置和重置前景颜色。

请注意我们不再用 String 作为文件名的类型了,而是用了 Option。它表明我们要么得到了文件名要么得到了 None,以防止没有设置文件名。
现在,我们已经准备好在状态栏中展示一些信息了。我们会展示最多 20 个字符的文件名,和文件的总行数。如果没有文件名,我们就展示 [No Name]。

我们确保当状态信息字符串在窗口中放不下时,切短一些。还要注意,我们仍然使用绘制空格到屏幕最右侧的代码,所以整个状态栏的背景都是白色的。
这里,我们使用了一个新的宏 format! 。它和 print!、println! 差不多。不过不会真的向屏幕打印什么东西(译者注:只拼接字符串)。
现在,展示当前所在行数,然后把它放到状态栏的右边。

当前行被存放在 cursor_position.y 中,因为位置是从 0 开始的,所以我们对其加 1。从要生成的空格数中减掉 line_indicator 的长度,然后添加它到最后的格式化字符串中。
状态信息
我们还要在状态栏下面添加一行。它会向用户展示信息,比如,在用户搜索时提示输入。我们会在一个叫 StatusMessage 的结构体中存储当前信息,它会被放在编辑器状态中。我们还会为该信息存储一个时间戳,从而可以在展示几秒后清除掉它。

我们用按键绑定的帮助信息来初始化 status_message 。如果不能打开文件,就修改status_message 为一个错误。既然需要展示 status_message,我们可以修改 draw_message_bar() 函数。首先,我们用 Terminal::clear_current_line(); 清空信息栏。因为总是重写整行,所以我们不需要在绘制前清空状态栏。然后,确保 status_message 与屏幕宽度匹配,只要该信息展示时间不超过 5 秒就展示它。这表示即使我们不再展示它了,还会保留上一次的 status_message。这样还行,因为这个数据结构还是比较小的,并且不会随时间增长。
现在启动程序,你会发现底部的帮助信息。当你 5 秒后按下某个按键,它就会消失。记住,我们只在按键按下后刷新屏幕。
在下一章中,我们会把文本阅读器变成一个文本编辑器,让用户可以插入和删除字符,并且能保存改动到磁盘中。
结论
前几章所有的重构都是值得的,因为在本章中我们能轻松且毫不费力地扩展编辑器的功能。像上一章一样,我希望你能用充满自豪的眼光来看自己的文本阅读器。
话不多说,让我们在下一章中把注意力放到编辑文本上来。