本文翻译自: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 秒后按下某个按键,它就会消失。记住,我们只在按键按下后刷新屏幕。
在下一章中,我们会把文本阅读器变成一个文本编辑器,让用户可以插入和删除字符,并且能保存改动到磁盘中。
结论
前几章所有的重构都是值得的,因为在本章中我们能轻松且毫不费力地扩展编辑器的功能。像上一章一样,我希望你能用充满自豪的眼光来看自己的文本阅读器。
话不多说,让我们在下一章中把注意力放到编辑文本上来。