Skip to content

【译】 第三章:原始输入输出

April 23, 2022 | 01:34 AM

本文翻译自:Hecto, Chapter 3: Raw input and output – Philipp Flenker – Engineering Manager,封面图也来源自此。

在这一章中,我们会解决从终端读取和向终端写入的问题。但是,首先,我们需要让代码更加地道。注意!本章的开头会包含大量的无聊文字,如果你不感兴趣,可以直接跳过。

写地道的代码

每当你和 Rust 这样的新语言打交道时,你会经常听到地道代码的说法。显然,让代码解决问题是不够的,它还应该是地道的。先让我们讨论一下这样为什么是有意义的。因为地道这个术语来自语言学,我们就举一个语言学的例子:如果我告诉你,有了本教程,你可以 “kill two flies with one swat”(直译为:一拍打死两个苍蝇,即一石二鸟、一箭双雕),因为你可以在学习 Rust 的同时,编写自己的编辑器。你能明白的我的意思吗? 如果你是个德国人,你大概不会有疑惑,因为 “Killing to flies with one swat” 是一句近似直译的德国谚语。如果你是俄罗斯人,“killing two rabbits with one shot”(直译为:一枪打死两只兔子) 对你来说会更好理解。但是如果你不了解德语和俄语,就不得尝试不从上下文中猜测(extract)句子的意思。因为地道的英文表达方式是 “To kill birds with one stone”(译者注:即中文成语一石二鸟)。重点是使用正确的英语俗语可以确保每个人无需思考便可理解意思。如果你说的语言不地道,你就会迫使人们思考你所使用的措辞,而不是你提出的观点。(译者注:作者说的地道指的是更加符合语言惯例和语法规则。比如那句出名的 Chinglish — Good Good Study,Day Day Up 对于中文母语者来说没有理解压力,但是英语母语者就会丈二和尚摸不着头脑。) 写地道代码也是类似的。对其他人来说,它更容易维护,因为它坚持某些规则和惯例,这也是编程语言设计者所考虑的。

你的代码通常只有在不能运行, 或者是功能缺失,有人想扩展功能, 或者是有 Bug 时才会被别人审核(Review)。让代码更容易被别人阅读和理解通常来说是一个好主意。

我们之前看到过,Rust 编译器能够给你一些关于地道代码的建议。 比如,我们在一个不使用的变量前加上了下划线。我的建议是不要忽略编译器的警告,你的最终代码应该总是在没有警告的情况下编译。

不过,在本教程中,我们有时会提前添加功能,这将导致 Rust 警告代码不可用(unreachable)。这通常在一两步之后就会被修复。

让我们通过使代码更地道开始本章吧。

读取按键而不是字节

在前一步中,我们直接操作字节,这是有趣且有价值的。然而,在某个时间段,你应该问下自己,正在实现的功能能否被一个库函数取代。在有些情况下,别人可能早已解决了你的问题,并且可能效果更好。对于我来说,用位操作处理是一个巨大的警示灯,它告诉我,我可能在细节中陷得太深。

幸运的是,我们的依赖 termion 让事情变得简单多了,因为它已经把单独字节与按键绑在了一起,并把它们传递给我们。就让我们实现这个功能吧。

到 Github 查看这一步。

我们现在处理的是按键而不是字节。有了这些修改,我们就能够摆脱手动检查 CTRL 是否被按下的做法,因为现在所有按键都被正确处理。termion 提供给我们表示按键的值:Key::Char(c) 表示每个输入的字符,Key::Ctrl(c) 表示所有同时与 CTRL 按下的字符,Key::Alt(c) 表示所有同时与 ATL 按下的字符等等。 不过,我们仍然主要关注输入的字符和 CTRL-Q

注意 match 内部的微小差异:Key::Char(c) 匹配任意字符,并绑定它到变量 c ,但是 Key::Ctrl('q') 专门匹配 CTRL-Q。在修改之前,我们的操作对象是字节,我们将其转换为字符,以便将其打印出来。现在,termion 帮我们处理了字符,所以为了打印输出字节值,我们使用 c as u8 来转换。 我们也在 match 内部添加了另一个匹配臂(case),这是一个特殊的匹配臂:对每一个尚未处理的可能情况(case),都会调用 _

match 需要覆盖所有情况(exhaustive),所以每个可能的情况都要被处理。对所有没有被处理的(情况),_ 是默认选项。如果按下的既不是字符也不是 CTRL-Q ,我们就把它打印出来。

我们也需要导入一些新的东西让我们代码能够运行。与 into_raw_mode 相似,我们需要导入 TermRead 来使用 stdin 中的 keys 方法,但是相应地需要删除 std::io::Read 的导入(译者注:上述两个库都实现了 keys 方法,避免冲突需要删掉 std::io::Read)。

将代码切分到多个文件

main 函数本身除了提供应用程序的入口外,不需要做更多的事,这才是地道做法。而且这在很多程序设计语言中都是一样的,Rust 也不例外。我们希望代码被放到它起作用的地方,所以,日后查找(locate)和维护代码会更容易。还有更多的好处,我会在遇到的时候解释。

现在我们的代码太底层。我们需要理解整个代码才能理解其意图。本质上来说,它只是简单地重复用户按下的任意按键,如果按下 CTRL-Q 则退出。通过创建编辑器的代码表示来改进代码吧。

首先创建一个新文件:

到 Github 查看这一步。

struct 是变量和函数的集合,它们被放在一起形成一个有意义的实体。在我们的例子中,就是编辑器(当然,现在还没有意义,但是我们会实现它的!)。pub 关键字告诉我们整个结构体可以在 editor.rs 外面访问。因为我们想在 main 中使用它,所以我们使用 pub。这也是代码切分的下个好处:可以让某些函数只能在内部调用,而将其它函数暴露给应用程序(system)的其它部分。

现在,我们的编辑器需要一些功能。让我们像下面这样实现一个 run() 函数:

到 Github 查看这一步。

你早就知道 run 的实现了 — 它是我们 main 函数的复制粘贴,包括导入和 die

让我们关注新的语法:impl 块包含可以在结构体上调用的函数实现(我们马上就能看到它是怎样工作的)。这个函数有 pub 关键字,所以我们能从外面调用它。run 函数接收一个叫做 &self 的参数,它包含对其调用的结构体的引用(self 之前的 & 表示我们正在处理的是引用)。这与 impl 块之外有一个接收 &Editor 作为第一参数的函数等价。

让我们通过重构 main.rs来看看这在实践中的作用:

到 Github 查看这一步。

如你所见,我们从 main.rs 中几乎删除了所有东西。我们创建了一个新的 Editor 实例,并调用它的 run() 。如果你现在运行代码,你应该能看到它顺利运行。现在,让我们把 main.rs 仅剩的几行代码变得更好一些。结构体允许我们组合变量,但是现在,我们的结构体是空的 — 它没有包含任何变量。一旦我们开始向结构体添加东西,我们就必须在创建新 Editor 时设置所有的字段。这意味着,对于每一个 Editor 的新字段(entry),我们必须要回到 main.rs 并修改 let editor = editro::Editor{} 这一行,设置所有新的字段值。这是糟糕的,所以让我们重构一下。

这是修改:

到 Github 查看这一步。

我们创建了一个叫作 default 的新函数,它为我们创建了一个新的编辑器(Editor)。注意 default 那一行不包含关键字 return, 而且还不以 ; 结尾。Rust 将函数中的最后一行作为其输出(返回值)。通过省略 ;,我们在告诉 Rust 我们对该行的值感兴趣,不只是执行它而已。试试加 ; ,看看发生什么。

不像 rundefault 不会被已存在的编辑器实例调用。这表现在 default 函数签名缺少 &self 参数。它被称为一个静态(static)方法。它们通过使用 :: 调用,像这样:Editor::default()

现在,我们可以不管 main.rs 而把注意力放到 editor.rs 上。

“看起来你在写一个程序,你需要帮助吗?”

让我们通过使用另一个非常有用的功能 — Clippy,来结束我们对地道代码的曲折(探索)之路。Clippy 是 Windows 95 中一个烦人的功能,也是一个指出我们代码中可能的改进点的工具。你可以在命令行中运行它:

cargo clippy

现在运行 Clippy 不产生结果 — 我们的代码足以通过 Clippy 默认的标记。然而这对我们来说是还不够的 — 我们希望 Clippy 能让我们烦,这样我们就能从中学习知识。首先,我们运行 cargo clean,因为 Clippy 只在编译期间产生输出。正如我们之前看到的那样 — Cargo 只编译修改过的文件。

cargo clean
cargo clippy -- -W clippy::pedantic

现在输出是:

Compiling libc v0.2.62
    Checking numtoa v0.1.0
    Checking termion v1.5.3
    Checking hecto v0.1.0 (/home/philipp/repositories/hecto)
warning: unnecessary structure name repetition
  --> src/editor.rs:30:9
   |
30 |         Editor{}
   |         ^^^^^^ help: use the applicable keyword: `Self`
   |
   = note: `-W clippy::use-self` implied by `-W clippy::pedantic`
   = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#use_self

    Finished dev [unoptimized + debuginfo] target(s) in 6.16s

译者注:在 2021 版本的 Rust 中,输出不同。因为介绍 Clippy 的功能是本段的主要目的,所以不再改动。读者只需理解其意图并了解怎样使用即可。

Clippy 不仅指出我们代码中的弱点,还提供了一个指向文档的链接,这样我们就可以阅读关于该错误的所有内容。这可太好了!

我们可以告诉 Clippy 我们想要默认使用的标记,比如通过向 main.rs 添加代码。让我们做一下,也修复一下它指出的问题。

腐朽(pedantic)设定对于初学者是很有价值的。因为我们还不知道怎样地道地写代码,我们需要某人在我们的旁边,指出事情怎样才能变得更好。

从验证中分离读取

让我们写一个读取按键输入的函数,和另一个用来映射按键输入到编辑器操作的函数。现在,我们也不再打印出按键输入。

到 Github 查看这一步。

我们添加了一个 loop 循环到 run 中。loop 会永远地重复直到被直接打断。在 loop 中我们使用了另一个 Rust 功能:if let。 它是我们使用 match 只处理一种匹配而忽略所有其他可能匹配的简写(shortcut)。看看 process_keypress() 中的代码,可以看到一个完全可以被 if let 代替的例子。

run 中,我们执行 self.process_keypress() 然后检查结果是否匹配 Err。 如果匹配,我们传递解包后的错误到 die 中,如果不匹配,什么也不会发生。

我们在检查 process_keypress 签名时,能够更清楚地看到这一点。

fn process_keypress(&self) -> Result<(), std::io::Error>

-> 后的部分说的是:该函数返回 Result。<> 中的东西告诉我们 OkErr 中预计的内容分别是什么 — Ok 将包裹 (),它表示没有东西;Err 会包裹 std::io::Error

process_keypress() 等待一个按键输入,然后对它执行一些处理。稍后,它会映射各种各样的 CTRL 按键组合和其他特殊按键到不同的编辑器功能,并插入任意字母数字和其他可打印的按键字符为可编辑的文本。这就是为什么我们这里使用 match 而不是 if let

该函数的最后一行对初学者来说有点难理解。从概念上来说,我们不想函数返回任何值,那么为什么返回 Ok(()) 呢?事实是:因为当调用 read_key 时会发生错误,我们想向上传递该错误到调用函数中去。因为没有 try..catch ,所以我们不得不返回一些表明『所有功能都运行正常』的东西,即使不返回任何值。这正是 Ok(()) 做的事:它说明『所有功能都运行正常』,因此没有东西被返回。

但是如果某些东西出错了呢?好吧,我们可以从 read_key 函数签名中辨别出错误能够传递给我们。如果是这种情况,就没有必要再继续,我们希望错误也能被返回。但是如果没有发生错误,我们希望继续使用解包后的值。 这也是 read_key 后的问号符为我们做的事:如果有错误,返回它;如果没有错误,解包其值并继续。 尝试玩一下这个概念,去掉 ? 或者是 Ok(()) 亦或修改返回值。

read_key 也包括了一个 loop。在这种情况下,loop 会重复运行直到函数返回,也就是说,只要一个有效的按键被按下,就返回。io::stdin().lock().keys().next() 跟我们刚讨论过的 Result 很相似 — 它就是所谓的 Option。我们会在后面更加深入地使用 Option。现在,了解 Option 可以是 None 就足够了 — 意味着在这种情况下,没有按键被按下,继续循环。或者,它也可以用 Some 包裹一个值,在该情况下,我们从 read_key 中返回解包后的值。

稍微有些麻烦的是,io::stdin().lock().keys().next() 的真实返回值是一个包裹在 Result 中的 Key。而这个 Result 又被包裹在 Option 中。所以我们在 read_key 中解包 Option,在 process_keypress() 中解包 Result

这就是错误进入到 run 中的方式,最终,它被 die 处理。说到 die,我们代码中有一个新的丑陋的点(wart):因为我们还不知道怎样从程序中退出,所以当用户使用 CTRL-Q 时,程序会 panic

我们倒是可以调用适当的方法来结束程序(std::process::exit,如果你感兴趣的话),但类似于我们不希望程序在代码内部随机崩溃那样,我们也不希望它在除 run 外的某个地方退出。我们通过向 Editor 结构体添加第一个元素 — 表明用户是否想要退出的布尔值,来解决该问题。

到 Github 查看这一步。

我们需要在 default 中初始化 should_quit ,否则我们不能编译代码。现在,让我们看看这个布尔值,当它是 true 时退出程序。

到 Github 查看这一步。

没有 panic,我们现在修改 should_quit,并在 run 中检查它。如果它是 true,我们使用关键字 break 结束循环。你应该发现现在退出程序比以前更清楚。

除了这个改变,我们不得不做一些其它事。因为现在我们在 process_keypress() 中修改了 self ,我需要修改函数签名中的 &self&mut self 。这说明我们想要修改所拥有的引用。如我们后面将要看到的,Rust 对待可变引用非常严格。

类似地,我们需要修改 run 的函数签名,因为我们在其中调用了 process_keypress()

最后也同样重要的是,我们不得不修改 mainlet editor = ... 表明 editor 是一个只读引用,所以我们不能在它之上调用 run。因为,它修改了 editor。我们本可以通过修改它为 let mut editor 来解决该问题。相反,因为对 editor 不执行任何操作,所以我们删掉了多余的变量,然后直接对 default() 的返回值调用 run()

现在,我们简化了 run,并且会试着保持现在这个样子。

清空屏幕

我们将要在每个按键输入后,渲染编辑器用户界面到屏幕上。让我们从清空屏幕开始。

到 Github 查看这一步。

我们加了一个新的函数 refersh_screen 。我们将在退出程序前调用它。我们把 process_keypress() 移到下面,这意味着用户退出程序后,我们仍然会在退出程序前再刷新一次屏幕。这能方便我们稍后打印退出信息。

为了清空屏幕,我们使用 print 来写 4 个字节到终端中。第一个字节是 \x1b ,它是转义字符,也是十进制中的 27 。另外三个字节是 [2J

我们正在写一个转义序列到终端中。转义序列总是以一个转义字符(27,如我们前面看过的,它由 Esc 产生)开头,后面紧跟着一个 [ 字符。转义序列知道终端做各种各样的文本格式化的任务,比如,彩色文本,向四周移动光标,和清空屏幕的部分内容。

我们使用 J 命令(消除显示)来清空屏幕。转义序列接收参数,这些参数在命令之前。在我们的例子中参数是 2。 它表示清空整个屏幕。\x1b[1J 会清空光标所在的位置。\x1b[0J 会清空从光标到屏幕底部的内容。当然,0J 命令的默认参数,所以只是 \x1b[J 本身也会清空从光标到屏幕底部的内容。

在本教程中,我们会主要参考 VT100 转义序列,它被现在终端模拟器广泛支持。查阅 VT100 用户指南,了解每个转义序列的完整文档。

写向终端之后,我们调用 flush() ,它强制 stdout 打印输出拥有的所有内容(它可能会缓存一些值并且不直接将它们打印出来)。我们也会返回 flush() 的结果(Result)。它与上面介绍的相似,要不什么也没包裹,也不在刷新失败的情况下,包裹一个错误 。不要忽略的是:如果我们在flush() 后面加上一个 ; ,就不会返回其结果。

termion 的帮助下,不需要直接向终端写转义序列,所以我们可以像下面这样修改代码:

到 Github 查看这一步。

从这里开始,我们将在代码中直接使用 termion 而不是转义字符。

顺便说一句,因为现在在每次运行程序时清空屏幕,我们可能会错过编译器给我们的有价值的建议。不要忘了,你可以单独运行 cargo build 看一看所有的警告。不过请记住,Rust 不会在你没修改代码的情况下再次编译代码,所以在 cargo run 之后立刻运行 cargo build 不会出现相同的警告。先运行 cargo clean 再运行 cargo build 重新编译整个项目,就会得到所有的警告。

改变光标位置

你可能注意到了,\x1b[2J 命令把光标放到了屏幕底部。让我们改变光标位置到左上角,这样我们就可以从上到下绘制编辑器界面了。

到 Github 查看这一步。

termion::cursor::Goto 背后的转义序列使用 H 命令(光标位置)来放置光标。H 命令实际上接收两个参数:将要放置光标的行号和列号。所以如果你有一个尺寸是 80 x 24 的终端,你想把光标放到屏幕的中间,你可以使用命令 \x1b[12;40H (多个参数被 ; 分隔)。因为行和列的编号是从 1 开始的,而不是 0,所以 termion 的编号也是从 1 开始的。

退出时清空屏幕

当我们的程序崩溃时,清除屏幕并重新定位光标。如果在渲染屏幕过程中发生了错误,我们当然不想一堆没用的东西留在屏幕中,我们也不想无论光标在哪,错误都被打印出来。借此机会还打印出一个退出信息,以免用户离开 hecto

到 Github 查看这一步。

波浪线

是时候开始绘制(屏幕)了。像 Vim 那样,让我们在屏幕左手边绘制一列的波浪线(~)。在我们的编辑器中,我们会在正在编辑的文件末尾后的任意行的开头画一个波浪线。

到 Github 查看这一步。

draw_rows() 将绘制正在编辑的文本缓冲区的每一行。现在,它在每一行绘制一个波浪线,这意味着该行不是文件的一部分,还不能包含任意的文本。

我们还不知道终端的尺寸,所以我们不知道有多少行需要绘制。现在,我们只绘制了 24 行(for..in_ 表示我们对它的值不感兴趣,只想重复命令多次)

在结束绘制之后,我们把光标重新放回到屏幕的左上角。

窗口尺寸

我们下一个目标是获取终端的尺寸,我们才能知道在 draw_rows() 中需要绘制多少行。正好 termion 给我们提供了一个获取终端尺寸的方法。我们将在一个新的数据结构中使用它。该数据结构代表终端。我们把它放在一个叫作 terminal.rs 的新文件中。

到 Github 查看这一步。

让我们首先看一下新文件的内容。其中,我们定义了 Terminal 和一个叫作 Size 的辅助结构体。在 default 中,我们获取了 termion 中的 terminal_size,并把它转换为一个 Size,然后返回 Terminal 的新实例。为了应对潜在的错误,我们把它包裹进 Ok 中。

我们也不想外面的调用方修改终端的尺寸。所以,我们没有用 pub 标记 size 为公共(字段)。退而求其次,我们添加了一个叫作 size 的新方法,它返回一个内部 size 的只读引用。

让我们快速过一遍这里的数据类型。Size.widthheight 都是 u16,它是一种无符号 16 位整型,最大值约为 65000(译者注:65535) 。这对终端来说够用,至少对几乎所有我见过的终端来说都是如此。

既然我们已经看过了新结构体,让我们看看它是怎样被编辑器(editor)引用的。首先,我们在 main.rs 中导入了新结构体,与我们导入 editor 的方式相同。然后,我们说我们想使用 Terminal 结构体,并在该语句前添加 pub 。这有什么用处呢?

editor.rs 中,我们现在可以使用 use crate::Terminal 来导入终端。如果没有 main.rs 中的 pub use 语句,我们不可能这样做,相反,我们需要使用 use crate::terminal::Terminal。实际上,我们在(crate)顶层重导出(re-export) Terminal 结构体,并让它可通过 crate::Terminal 来访问。

在我们 editor 结构体中,我们添加了一个指向终端的引用,并在 default() 中初始化。请记住 Terminal::default 返回一个终端或者一个错误。我们用 expect 解包该终端(Terminal::default的返回值)。它的作用如下:如果我们得到了值(终端),就返回它。如果我们没有得到值(译者注:即Terminal::default 返回了一个错误),就用传给 expect 的文本 panic。我们不必在这调用 die,因为 die 主要是在我们重复向屏幕绘制(画面)时有用。

也有其他方法来处理该错误。我们本可以在 Terminal 中使用 expect,但是这不是重点。重点是 Rust 强迫你仔细考虑,即你必须慎重决定做什么,否则程序甚至不会通过编译。

现在我们代码中有了终端的表示,让我们在此基础上修改一下代码。

到 Github 查看这一步。

我们做了什么?我们把所有底层终端的东西都移到了 Terminal 中,把高阶抽象的东西留在了 editor.rs 中。在这一过程中,我们清理了一些东西:

关于溢出简单的几句话:我们的类型有一个最大尺寸。像前面提到的那样,该限制对 u16 来说大约是 65000 。所以,如果你对最大值加一会发生什么呢?它变成了相应类型的最小值,所以在无符号整型的情况下是 0 。这就被称作溢出。为什么会发生这样的事?让我们思考一个 3 比特位的数据类型。我们从表示 0 的数值 000 开始,每当我们想增加 1 时,我们就使用以下算法:

就产生下面的序列:

数字二进制
0000
1001
2010
3011
4100
5101
6110
7111

现在如果我们想试着再加 1,会发生什么呢?所有的 1 都会变为 0,但是没有多余的比特来变为 1,所以我们回到了 000 ,它表示 0 。

顺便说一下,Rust 中正常处理溢出的方式是这样的:在调试(debug)模式下(我们默认使用的就是该模式),程序瘫痪。这是你期待的:编译器不应该尝试保持程序运行,而是抛出 bug 到你的面前。在发布(production)模式下,允许溢出发生。这也是你期待的,因为你不想在生产环境下程序意外瘫痪,相反,它能够继续处理溢出值。在我们的例子中,这意味着光标不是放在屏幕的底部或者右侧,而是在左侧(或顶部)。这是令人反感的,但是还不至于导致(程序)瘫痪。幸运的是,我们的新代码无论如何都避免了这种情况的发生:我们使用 saturating_add 来尝试加 1,如果不能继续加 1,就返回最大值。

(如果你想尝试 Rust 在发布模式下处理溢出的逻辑: 你可以使用 cargo build --release 构建你用于发布的应用程序,它会把发布下的可执行文件放到 target/release 目录下。)

最后一行

你或许注意到了屏幕的最后一行没有波浪线。这是因为我们代码中有一个小 bug。当我们打印完最后一个波浪线后,像其它行那样又打印了 "\r\n" (译者注:\n 来自 println!),但是这会导致终端为一个新空行创造空间向下滚动。因为我们以后想在底部设置一个状态栏,所以我们现在只需修改待绘制行的范围。我们稍微会再次讨论,并让它在处理溢出或下溢时更鲁棒,但是现在我们关注于让它跑起来。

到 Github 查看这一步。

在重新绘制时隐藏光标

还有一个我们将要处理的烦人的闪烁效果的根源。有可能在终端向屏幕绘制图像时,光标会在屏幕中间的某个地方闪烁一下。为了确保它不会发生,在刷新屏幕前隐藏光标,刷新完成后立刻再次显示。

到 Github 查看这一步。

从实现细节上来说,我们通过写入h 命令(设置模式\x1b[25hl 命令(重置模式\x1b[25l 这两个转义序列来告诉终端隐藏和展示光标。这些命令被用来打开和关闭各种各样的终端的功能或『模式』。超链接中的 VT100 用户指南没有说明我们上面用到的参数 25。好像光标隐藏和展示功能出现在后面的 VT 模型中。

一行一行地清空

与其在每次刷新前清空整个屏幕,不如在重新绘制时清空每行,这样似乎更合适。让我们在每行的开头用 \x1b[k 序列(termion::clear::CurrentLine)替换转义序列 termion::clear::All (清空整个屏幕)。(译者注:关于 \x1b[k 请查看该链接

到 Github 查看这一步。

请注意我们现在在显示退出信息前清空屏幕,以避免在程序终止之前在其它行上显示的信息保留下来的情况。

欢迎信息

或许是时候显示欢迎信息了。让我们在屏幕的三分之一处显示我们编辑器的名字和版本号。

到 Github 查看这一步。

我们在代码中添加了一个叫作 VERSION 的常量。因为 Cargo.toml 早已包含了版本号,我们使用 env! 宏来检索它的值。我们添加它到欢迎信息中。

然而,我们需要处理的是,由于终端尺寸的原因,欢迎信息可能会被截断。我们现在就解决这个问题。

到 Github 查看这一步。

(译者注:58 到 60 行的代码如下)

let welcome_message = format!("Hecto editor -- version {}", VERSION);            
let width = std::cmp::min(self.terminal.size().width as usize, welcome_message.len());                            
println!("{}\r", &welcome_message[..width])

[..width] 语法表示我想获取字符串从开始到 width (不包含)的切片。width 表示屏幕宽度和欢迎信息长度的最小值。这样确保我们不会切出比现有的更多的字符串。

现在让我们居中排列欢迎信息,同时,把绘制欢迎信息的代码移到一个单独的函数中。

到 Github 查看这一步。

为了居中放置一个字符串,你将屏幕宽度除 2,然后从中减掉字符串长度的一半。也就是 width/2 - welcome_len/2 ,简写为 (width - welcome_len)/2 。它告诉你在离屏幕左侧边界多远的地方开始打印字符串。所以除了第一个应该是波浪线的字符,我们用空格填充这块空间。repeat 是一个很棒的辅助函数,它重复我们传入的字符 i 次,如果需要的话,truncate 缩短字符串到一个指定的宽度。

你应该能够确认它是有效的:如果字符串很宽,它会被截断,否则欢迎信息被居中放置。

移动光标

现在,让我们把注意力放到输入上。我们希望用户能够向四周移动光标。第一步是追踪光标在编辑器中的 x 和 y 位置。我们会添加另外的结构体来帮助我们实现。

到 Github 查看这一步。

cursor_position 是一个结构体,其中 x 会保存光标的水平坐标(列),y 会保存垂直坐标(行),其中 (0,0) 在屏幕的左上角。我们初始化它们为 0,因为我们希望光标从屏幕左上角开始。

这里有两个点值得注意。尽管你可能会想当然以为我们在终端中修改光标位置,但是我们不会添加 PositionTerminal 中。当然,我们在那记录它,也是自然的。然而,cursor_position 一会儿就要描述光标在当前文档中的位置,而不是在屏幕上的位置。因此,它与在终端上的光标的位置是不一样的。

另一个直接相关的点是:虽然我们使用 u16 作为我们终端尺寸的数据类型,但是我们使用 usize 来描述光标的位置。像上面讨论的那样,u16 只能到大概 65000,这对我们的目的来说太小了—这意味着 hecto 不能处理超过 65000 行的文档。但是 usize 有多大呢?答案是,取决于我们要编译的平台架构是 32 位还是 64 位(译者注:64 位平台为 $x^{64}-1$,32 位平台为 $x^{32}-1$)。

现在,让我们在 refresh_screen() 函数中添加代码来朝 cursor_position 存储的位置移动光标。与此同时,重写 cursor_position 函数,让它接收一个 Position

到 Github 查看这一步。

我们使用解构来初始化 cursor_position 中的 xylet Position(mut x, mut y) = position; 会创建新变量 x 和 y,并绑定它们的值到 position 中的名字相同的字段下。

现在,你可以尝试用不同的值初始化 cursor_position ,确认目前为止代码代码如我们设想的那样工作。

我们也把 Position 中的 usize 数据类型转换为 u16u16 不能存储超过 u16 上限的值,否则值会被截断。现在暂时是没问题的—我们稍后会添加逻辑来确保总是在 u16 上限内。所以,我们在这添加了一个 directive 来告诉 Clippy 不要再用这种错误警告我们。

说起烦人的 Clippy 警告,我们现在正被一个出现很久的 Clippy 警告打扰,现在我们就要解决这个问题。接下来,我们会允许用户使用方向键来移动光标。

到 Github 查看这一步。

译者注:56 行代码如下:Key::Up | Key::Down | Key::Left | Key::Right => self.move_cursor(pressed_key),

除此之外,还要修改 refresh_screen 中下图黄色行。

不知道你有没有和我一样的疑问:为什么 62 和 70 行 xy 的顺序不同呢?x不是代表列,y代表行吗? 其实这里的顺序不重要。无论是初始化结构体还是解构,只要变量名和字段名相同,Rust 会自动将字段与变量一一对应(如果还是不太理解,可以看看这个例子)。

现在你应该可以用方向键来移动光标了。

防止光标移出屏幕

现在,你能让 cursor_position 的值超过屏幕的右侧和底部边界。让我们通过在 move_cursor() 中做一些边界检查来防止这种情况发生。

到 Github 查看这一步。

你应该能够确认现在可以在可见区域内四处移动,并且光标会一直呆在终端边界内。你也可以把光标放到最后一行,它仍然没有波浪线。别忘了这个事,本教程后面会修复。

使用 PAGE UPPAGE DOWNHOMEEND 操作

为完成底层终端代码,我们需要检测更多的特殊按键。我们将分别映射 PAGE UPPAGE DOWNHOMEEND 为放置光标到屏幕顶部、屏幕底部、行开头和行结尾。

到 Github 查看这一步。

结论

我希望这一章能让你在看到你的文本编辑器的雏形时,有一种初次的自豪感。我们在开头讨论了很多关于地道代码的内容,然后花了很长时间忙着重构代码到几个单独的文件中,但是结果是显而易见的:代码结构清晰,因此容易维护。由于我们现在对 Rust 有了一定了解,所以在接下来的章节中,我们不必过于担心重构的问题,可以将注意力放到增加功能上面。

下一章,我们会让程序展示文本文件。