文章
从零构建一个文件编辑器-3
我们将在这里探索从终端读取和写入内容的细节。但在深入探讨之前,让我们先完善一下代码,使其更符合标准实践
部分内容超过 Notion API 单页读取上限,已尽力加载可访问内容。
Raw输入和输出#
将代码拆分为多个文件#
Rust与许多编程语言一样,main 方法通常只是提供应用的入口点,仅起到启动作用,而不会做其他事情。我们的目标是将代码放置在逻辑上合理的位置,以便在后续开发过程中更容易找到和维护。这种方法还有很多其他好处,我会在遇到时指出来。
现在,我们的代码有点难以理解。要真正理解它的作用,你必须深入研究它的全部内容。简而言之,它会回显用户按下的每个键,并在按下“q”时退出。让我们简化一下,一举两得:我们将为我们的编辑器引入一个名为Editor 的结构体,它将放在一个单独的文件editor.rs中。这将使代码更容易理解,也更符合语言习惯。
现在我们可以把注意力放在editor.rs上,editor.rs的内容可以是这样的:
use std::io;
use std::io::Read;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
pub struct Editor{}
impl Editor {
pub fn default() -> Self {
Editor{}
}
pub fn run(&self) {
enable_raw_mode().unwrap();
for b in io::stdin().bytes() {
match b {
Ok(b) => {
let c = b as char;
if c.is_control() {
println!("Binary: {0:08b} ASCII: {0:#03} \r", b);
} else {
println!("Binary: {0:08b} ASCII: {0:#03} Character: {1:#?}\r", b, c);
}
if c == 'q' {
break;
}
}
Err(err) => println!("Error: {}", err),
}
}
disable_raw_mode().unwrap();
}
}
其实大体的逻辑就是把main.rs中的代码逻辑移动到了editor.rs中,然后我们修改main.rs的内容
use crate::editor::Editor;
mod editor;
fn main() {
let editor = Editor::default();
editor.run();
}
由于main.rs原来的代码逻辑迁移到了editor.rs中,所以现在新的main.rs代码很简洁
改进的错误处理#
让我们通过解决关于错误处理的来总结一下惯用代码。我们的目标是:
- 本地处理: 如果我们能够在错误发生的地方解决它,我们就会处理它。
- 向上传递: 对于我们无法直接修复的错误,我们会将其传递到上级链以进行进一步处理。
- 顶级处理: 如果错误达到最高级别,我们将尽可能优雅地处理它。 作为其中的一部分,我们将错误处理逻辑与主循环分开,并打印出一条简单的再见消息。
use crossterm::event::{read, Event::Key, KeyCode::Char};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
pub struct Editor {}
impl Editor {
pub fn default() -> Self {
Editor {}
}
pub fn run(&self) {
if let Err(_err) = self.repl() {
panic!("{_err:#?}");
}
print!("Goodbye.\r\n");
}
fn repl(&self) -> Result<(), std::io::Error> {
enable_raw_mode()?;
loop {
if let Key(event) = read()? {
println!("{event:?} \r");
if let Char(c) = event.code {
if c == 'q' {
break;
}
}
}
}
disable_raw_mode()?;
Ok(())
}
}
按 Ctrl-Q 退出#
目前,只要有人输入q,我们的文本编辑器就会退出,这不太理想。让我们来改变一下,让它只在你点击 时退出Ctrl-Q。
此外,随着代码越来越复杂,函数越来越多,我们需要一种更流畅的方法来关闭程序,而无需手动中断整个循环。
我们将通过调整Editor结构体来解决这个问题,使其包含一个名为 的布尔值should_quit。此修复还将消除 Clippy 的最终警告。
您可以在此处查看我们如何进行这些更改。
清除屏幕#
我们将深入研究每次按下按键时编辑器的用户界面是如何渲染的。这发生在三个关键时刻:
- 启动: 我们正在为用户做好准备。
- 每次按键后: 响应用户的操作。
- 离开前: 清理我们的工作空间并留下整洁的告别信息。 现在让我们的代码更加清晰、更加结构化:
- 首先,我们启用原始模式并清除屏幕,设置我们的编辑器。
- 最后,我们进行清理:禁用原始模式,再次清除屏幕,处理任何错误,并告别用户。
- 在这期间,我们的循环读取输入,求值,更新屏幕,如此反复——为了我们以后的理解,保持代码整洁易懂。最好趁代码还好理解的时候开始清理。
从现在开始,我们将不再回显每次按键,保持屏幕清晰易懂。
看看这次更新的实际效果。
测试一下效果,你会发现屏幕按预期清晰显示,按键回声消失,退出也很流畅。我们距离将 hecto 改造成功能齐全的文本编辑器又近了一步!
最后,请记住,每次清除屏幕都会遮挡编译器提示。要捕获任何警告,请
cargo build单独运行。请记住,Rust 不会重新编译未更改的代码,因此要获取一组新的警告,cargo clean请先运行,然后再运行cargo build。
绘制波浪线#
现在该开始绘图了。你的第一个任务是~在屏幕左侧绘制一列波浪号 ( ),就像vim那样。在我们的文本编辑器中,我们将在正在编辑的文件末尾之后的任何行的行首绘制一个波浪号。
“绘图”的意思是:我们将光标移动到想要绘制的位置,然后打印出想要绘制的内容。我们之前没有移动光标,导致“再见”消息打印在终端的某个位置,而不是左上角。所以我们计划将光标定位到左上角,开始逐个绘制波浪线,到达底部后,再将光标定位回左上角。
任务如下:
- 实现一个名为
draw_rows()的函数。该函数应在每一行打印一个~。crossterm::cursor::MoveTo移动光标。crossterm::terminal::size为您提供尺寸。
- 重构:你会注意到,每当我们与 交互时,我们都在做很多类似的事情
crossterm。现在是时候将终端的至少一部分功能封装到它自己的文件中了。- 如果您在将新文件导入编辑器时遇到问题,请尝试使用
mod main.rs。 代码在这里
- 如果您在将新文件导入编辑器时遇到问题,请尝试使用
更好地绘制波浪号#
我对我们写入终端的方式不太满意。我们之前用的是println!宏,上一步改用了print!。但我们现在也用了crossterm,其中用到了一个叫做execute!的宏的结构。这很糟糕,原因有三:
一. 我有点恼火,因为我们把直接打印和crossterm两个概念混在一起了。对我来说,这种恼火就像你走进房间时闻到的某种味道。这通常被称为代码异味:可能预示着更深层次的问题。
二. 我们还没搞懂execute!,只是照着文档说的用了。我觉得这也有点可疑。
三. 打印有时很奇怪,我们的代码没有考虑到这一点。
让我们从最后一部分开始,看看打印到底有多奇怪。打印到屏幕是一项开销很大的操作,为了提高效率,我们引入了缓冲区的概念。每当你尝试在屏幕上写入时,实际上都是写入这个缓冲区,而无论你手动操作还是缓冲区已满,这个缓冲区都会被清空(并写入屏幕)。我们目前还没有遇到这个问题,因为缓冲区通常是行缓冲的,所以它最迟会在缓冲区中有满行时自动写出。到目前为止,我们所有的打印操作都包含换行符,所以我们没有发现这个问题。但是,写入转义序列并不一定打印出换行符,这意味着我们可能会遇到缓冲问题。
这将引出第二部分,即我们不了解我们正在使用的内容。execute!实际上是一个宏,可确保立即打印出传递给它的任何内容。这..不错吧?这几乎就是我们想要的,但正如我上面提到的,写入是一项昂贵的操作,我们实际上只需要在完成按键求值后才需要完全写入屏幕。我们不需要为每个光标位置、清除屏幕或写入都进行完整的写入,而可以将其留给缓冲机制,并可以让系统找出写入每项内容的最佳时机。如果我们始终写出每个细微的变化,这甚至可能导致奇怪的闪烁效果。
这就引出了我的第一点:代码异味。为了研究代码异味,我们深入研究了execute! 的各种资料,并发现了打印代码有多么奇怪。最终,我们明白了,我们的代码之所以能这样工作,完全是出于偶然,而且效率很低。
我们现在应该处理另一个可能导致恼人的闪烁效果的因素。当终端绘制到屏幕时,光标可能会在屏幕中间的某个地方短暂地显示。为了避免这种情况发生,我们可以在刷新屏幕之前隐藏光标,并在刷新完成后立即再次显示它。
然后,与其在每次刷新之前清除整个屏幕,不如在重新绘制每一行时清除它们,这似乎是更理想的选择。
以下是完整的任务:
- 在刷新屏幕之前使用
Hide来隐藏光标。 - 刷新完成后使用Show显示光标。
- 使用适当的ClearType仅清除您想要重新绘制的内容。
- 重构当前实现,不再直接使用
print!打印,而是使用crossterm的Print。 - 重构当前实现,不再使用
execute!。改为使用queue!,使用方法完全相同。您需要手动调用stdout.flush();,在适当的时间触发写入操作,以确保写入操作成功。提示:要调用此功能,您需要use std::io::Write;。 - 额外重构:
size()返回一对有序值,称为元组。如果我们能以某种方式处理struct,就能更明确地判断这两个值中哪个是height,哪个是width? - 额外重构 2:
MoveTo使用两个参数来表示 x 和 y 位置。如果我们能以某种方式使用struct,就能更明确地指出这两个值中哪个是 x 位置,哪个是 y 位置,那岂不是更好? 优化后的代码输出欢迎辞#
在屏幕三分之一处居中显示hecto的版本号,然后使用trait进一步简化代码terminal.rs。 代码如下插入符号移动#
现在让我们关注输入。我们希望用户能够移动插入符号。我们还希望跟踪插入符号的当前位置,因为在接下来的章节中,我们将把插入符号的位置演变为文档中点的位置。- 跟踪屏幕上光标的当前位置。您可以选择将其实现为跟踪
LocationPosition(表示我们在文本中的位置),或者将其视为(表示我们在屏幕上的位置)。 - 监听更多
KeyCodes以允许移动Up,Down,Left,Right。决定在哪个点将光标放置在屏幕上的正确位置。 - 确保光标停留在屏幕边界内,因此,例如
Right在最右边的位置按下不会产生任何反应 要检查这一点,请确保如果在最右边的位置按下一次右键,那么只需按下一次左键即可向左移动一步。 - 使用
PageUp、PageDown、Home和End分别将光标移动到屏幕的顶部、底部、左侧和右侧。 - 无需正确处理终端大小的调整。 代码如下 下一章https://app.notion.com/p/1f85c571bb7d807381cbe863d6f306dc
- 跟踪屏幕上光标的当前位置。您可以选择将其实现为跟踪
📎 参考文章#
未支持的 Notion 内容:external_object_instance 在 Notion 中打开