返回文章列表

文章

从零构建一个文件编辑器-3

我们将在这里探索从终端读取和写入内容的细节。但在深入探讨之前,让我们先完善一下代码,使其更符合标准实践

部分内容超过 Notion API 单页读取上限,已尽力加载可访问内容。

目录
  1. Raw输入和输出
  2. 将代码拆分为多个文件
  3. 改进的错误处理
  4. 按 Ctrl-Q 退出
  5. 清除屏幕
  6. 绘制波浪线
  7. 更好地绘制波浪号
  8. 输出欢迎辞
  9. 插入符号移动
  10. 📎 参考文章

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。现在是时候将终端的至少一部分功能封装到它自己的文件中了。
    • 如果您在将新文件导入编辑器时遇到问题,请尝试使用mod main.rs代码在这里

更好地绘制波浪号#

我对我们写入终端的方式不太满意。我们之前用的是println!宏,上一步改用了print!。但我们现在也用了crossterm,其中用到了一个叫做execute!的宏的结构。这很糟糕,原因有三: 一. 我有点恼火,因为我们把直接打印和crossterm两个概念混在一起了。对我来说,这种恼火就像你走进房间时闻到的某种味道。这通常被称为代码异味:可能预示着更深层次的问题。 二. 我们还没搞懂execute!,只是照着文档说的用了。我觉得这也有点可疑。 三. 打印有时很奇怪,我们的代码没有考虑到这一点。 让我们从最后一部分开始,看看打印到底有多奇怪。打印到屏幕是一项开销很大的操作,为了提高效率,我们引入了缓冲区的概念。每当你尝试在屏幕上写入时,实际上都是写入这个缓冲区,而无论你手动操作还是缓冲区已满,这个缓冲区都会被清空(并写入屏幕)。我们目前还没有遇到这个问题,因为缓冲区通常是缓冲的,所以它最迟会在缓冲区中有满行时自动写出。到目前为止,我们所有的打印操作都包含换行符,所以我们没有发现这个问题。但是,写入转义序列并不一定打印出换行符,这意味着我们可能会遇到缓冲问题。 这将引出第二部分,即我们不了解我们正在使用的内容。execute!实际上是一个宏,可确保立即打印出传递给它的任何内容。这..不错吧?这几乎就是我们想要的,但正如我上面提到的,写入是一项昂贵的操作,我们实际上只需要在完成按键求值后才需要完全写入屏幕。我们不需要为每个光标位置、清除屏幕或写入都进行完整的写入,而可以将其留给缓冲机制,并可以让系统找出写入每项内容的最佳时机。如果我们始终写出每个细微的变化,这甚至可能导致奇怪的闪烁效果。 这就引出了我的第一点:代码异味。为了研究代码异味,我们深入研究了execute! 的各种资料,并发现了打印代码有多么奇怪。最终,我们明白了,我们的代码之所以能这样工作,完全是出于偶然,而且效率很低。 我们现在应该处理另一个可能导致恼人的闪烁效果的因素。当终端绘制到屏幕时,光标可能会在屏幕中间的某个地方短暂地显示。为了避免这种情况发生,我们可以在刷新屏幕之前隐藏光标,并在刷新完成后立即再次显示它。 然后,与其在每次刷新之前清除整个屏幕,不如在重新绘制每一行时清除它们,这似乎是更理想的选择。 以下是完整的任务:

  • 在刷新屏幕之前使用Hide来隐藏光标。
  • 刷新完成后使用Show显示光标。
  • 使用适当的ClearType仅清除您想要重新绘制的内容。
  • 重构当前实现,不再直接使用print!打印,而是使用crosstermPrint
  • 重构当前实现,不再使用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在最右边的位置按下不会产生任何反应 要检查这一点,请确保如果在最右边的位置按下一次右键,那么只需按下一次左键即可向左移动一步。
    • 使用PageUpPageDownHomeEnd分别将光标移动到屏幕的顶部、底部、左侧和右侧。
    • 无需正确处理终端大小的调整。 代码如下 下一章https://app.notion.com/p/1f85c571bb7d807381cbe863d6f306dc

📎 参考文章#

未支持的 Notion 内容:external_object_instance 在 Notion 中打开