Rust学习-制作一个文本编辑器: Raw Input And Output

本文最后更新于:2024年1月10日 下午

使用代替Byte

在之前的步骤中,我们直接操作字节。然而,本着“不重复造轮子”的原则,我们可以使用各种库函数已经实现的方法。termion就已经提供了这个方法,它可以将单个字节组合成按键输入,并传递给我们:

src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use std::io::{self, stdout};
use termion::raw::IntoRawMode;
use termion::event::Key;
use termion::input::TermRead;

fn die(e: std::io::Error){
panic!("{}", e);
}

fn main() {
let _stdout = stdout().into_raw_mode().unwrap();

for key in io::stdin().keys(){
match key{
Ok(key) => match key{
Key::Char(c) => {
if c.is_control() {
println!("{:?}\r", c as u8);
} else {
println!("{:?} ({}) \r", c as u8, c);
}
}
Key::Ctrl('q') => break,
_ => println!("{:?}\r", key),
},
Err(err) => die(err),
}
}
}

我们已经从处理原始字节转变为处理按键输入。利用termion,无需手动检测Ctrl是否被按下,因为它能自动处理所有按键事件。例如,Key::Char(c)代表单独的字符按键,而Key::Ctrl(c)Key::Alt(c)分别代表与CtrlAlt键同时按下的字符。我们的焦点主要在字符输入上,尤其是像Ctrl-Q这样的组合键。

在使用match进行模式匹配时,会对按键进行区分处理:Key::Char(c)可以匹配任何字符,Key::Ctrl('q')则专门匹配Ctrl-Q组合。此前,我们将字节转换为字符以输出,而现在,termion提供了直接的字符输出,通过将字符转换为其对应的字节值来输出。

我们在match表达式中添加了一个默认分支_,用于捕获所有其他未特别处理的情况。match必须包含所有可能性,因此_作为一个通配符,确保了所有未预见的输入都得到了处理。若有输入既不是普通字符也不是特定组合键,就将其原样输出。

除此之外,为了简化代码,我们导入了TermRead特性,这样就可以使用keys方法来处理stdin输入,这比之前直接处理读取操作要简洁得多。

将代码划分到多个源文件

Rust和许多其他编程语言中,main函数更多的是作为应用程序的启动入口,而不包含其他复杂逻辑。这是为了让代码更容易找到和维护。目前我们手头的代码层次较低,要理解整个代码,本质上它只是简单地将按下的键回显给用户,并在按下Ctrl-Q时退出。让我们通过创建多个文档来改进这段代码。

src/editor.rs
1
pub struct Editor {}

Rust中,struct是变量和函数的集合,它们组合在一起构成了一个有意义的实体,在我们的例子中就是editor。关键字pub表明这个结构体可以从editor.rs文件外部访问。我们想要在main函数中使用它,因此我们使用了pub。这正展示了将代码分离的另一个好处:我们可以确保某些函数只在内部调用,同时将其他函数暴露给系统的其他部分。

现在,我们的编辑器需要一些功能。让我们为它提供一个run()函数:

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use std::io::{self, stdout};
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::IntoRawMode;

pub struct Editor {}

impl Editor {
pub fn run(&self) {
let _stdout = stdout().into_raw_mode().unwrap();

for key in io::stdin().keys(){
match key{
Ok(key) => match key{
Key::Char(c) => {
if c.is_control() {
println!("{:?}\r", c as u8);
} else {
println!("{:?} ({}) \r", c as u8, c);
}
}
Key::Ctrl('q') => break,
_ => println!("{:?}\r", key),
},
Err(err) => die(err),
}
}
}
}

fn die(e: std::io::Error){
panic!("{}", e);
}

run函数接受一个名为&self的参数,它将包含对被调用结构体的引用(&表明我们在处理一个引用)。这相当于在impl代码块外部有一个接受&Editor作为第一个参数的函数。

让我们通过重构我们的main.rs

src/main.rs
1
2
3
4
5
6
7
8
mod editor;

use editor::Editor;

fn main() {
let editor = Editor {};
editor.run();
}

main中,我们创建了一个Editor的新实例,并在其上调用run()方法。现在,让我们改进main中剩下的几行代码。结构体允许我们将变量组合在一起,但目前我们的结构体是空的——它不包含任何变量。一旦我们开始往结构体中添加内容,我们就需要在创建新的Editor实例时立即设置所有字段。这意味着每当在Editor中添加新条目时,我们都必须回到main函数中,并修改let editor = editor::Editor{};这行代码来设置新的字段值。这样做并不理想,所以让我们重构一下这部分代码。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
/*codes*/

impl Editor {
/*codes*/

pub fn default() -> Self {
Editor{}
}
}

/*codes*/
src/main.rs
1
2
3
4
5
6
7
8
mod editor;

use editor::Editor;

fn main() {
let editor = Editor::default();
editor.run();
}

我们现在创建了一个名为default的新函数,用于构建一个Editor实例。值得注意的是,default函数的最后一行没有包含return关键字,也没有以分号;结尾。Rust会将函数中最后一行的结果作为其输出,并且通过省略分号。

run不同,default函数不是在已有的Editor实例上调用的,这一点从函数签名中缺少&self参数可以看出。default是一个静态方法,通过使用::调用。

分离读取和处理

让我们创建一个用于读取按键的函数,以及另一个函数用于将按键映射到编辑器操作:

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/*codes*/
impl Editor {
pub fn run(&self) {
let _stdout = stdout().into_raw_mode().unwrap();

loop {
if let Err(error) = self.process_keypress() {
die(error)
}
}
}

/*codes*/

fn process_keypress(&self) -> Result<(), std::io::Error>{
let pressed_key = read_key()?;
match pressed_key {
Key::Ctrl('q') => panic!("Program end"),
_ => ()
}
Ok(())
}
}

fn read_key() -> Result<Key, std::io::Error> {
loop {
if let Some(key) = io::stdin().lock().keys().next() {
return key;
}
}
}
/*codes*/

我们现在在run函数中添加了一个循环。循环会一直重复执行,直到被明确地中断。在这个循环内,我们使用了Rust的另一个特性:if let。这是match的简写形式,用于只处理一个情况而忽略所有其他可能的情况。你可以查看process_keypress()函数的代码,了解一个可以完全用if let替代的match情况。

run中,执行self.process_keypress()并检查结果是否匹配Err。如果是,就将错误解包并传递给die函数,如果不是,则不做任何事情。

我们可以通过查看process_keypress函数的声明来更清晰地了解这一点:

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

->后面的部分表明:这个函数返回一个Result类型。<>里的内容告诉我们OkErr分别包含什么内容:Ok会包裹一个(),意味着无内容,而Err会包裹一个std::io::Errorprocess_keypress()函数等待按键输入,然后处理它。随后,它会将各种Ctrl组合键和其他特殊键映射到不同的编辑器功能,将任何字母数字和其他可打印键的字符插入到正在编辑的文本中。这就是为什么在这里使用match而不是if let

这个函数的最后一行可能有点难以理解。我们不希望函数返回任何内容,那么为什么要用ok(())呢?这是因为:调用read_key时可能会出现错误,我们希望将这个错误传递给调用函数。由于Rust没有try..catch,我们需要返回一个表示一切正常的东西,即使我们实际上没有返回任何值。这正是ok(())所做的:它表示:一切正常,没有返回任何内容

但如果出现了错误怎么办呢?从read_key的声明中我们可以看出,错误可能会传递给我们。如果真的如此,就没有继续执行的必要了,我们希望错误也被返回。但如果没有发生错误,我们希望继续执行,并使用未包装的值。let pressed_key = read_key()?中的?的就是为此服务的:如果有错误,就返回它,如果没有,就解包值并继续

read_key函数也包含一个循环,在按下有效键后循环会结束。io::stdin().lock().keys().next()返回的值与上文讨论的Result很相似——这是一个叫做Option的类型。目前你只需要知道Option可以是None——在这种情况下意味着没有按键被按下,循环会继续;或者它可以包裹一个Some值,在这种情况下我们返回从read_key中解包的值。

更复杂的是io::stdin().lock().keys().next()的实际返回值是一个包裹在Result里的Key,而这个Result又被包裹在一个Option里。我们在read_key()中解包Option,然后在process_keypress()中处理Result

这就是错误如何传递到run中并最终由die处理的方式。说到die,我们的代码中出现了一个新的问题:由于我们还不知道如何从程序内部退出,所以当用户按下Ctrl-Q时,我们现在会调用painc

我们可以调用一个适当的方法来结束程序(如std::process::exit),但正如我们不希望程序在代码深处随机崩溃一样,我们也不希望它在代码的深层部分退出。我们通过向Editor结构体添加一个元素来解决这个问题:一个布尔值,用于指示用户是否想要退出:

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-pub struct Editor {}
+pub struct Editor {
+ should_quit: bool,
+}

impl Editor {
pub fn run(&self) {
let _stdout = stdout().into_raw_mode().unwrap();

loop {
if let Err(error) = self.process_keypress() {
die(error)
}
}
}

pub fn default() -> Self {
- Self {}
+ Self { should_quit: false }
}

我们需要在default函数中立即初始化should_quit,否则我们的代码无法编译。现在就设置这个布尔值,并在它为true时退出程序。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
 impl Editor {
- pub fn run(&self) {
+ pub fn run(&mut self) {
let _stdout = stdout().into_raw_mode().unwrap();

loop {
if let Err(error) = self.process_keypress() {
die(error)
}
+ if self.should_quit {
+ break;
+ }
}
}

pub fn default() -> Self {
Self { should_quit: false }
}

- fn process_keypress(&self) -> Result<(), std::io::Error>{
+ fn process_keypress(&mut self) -> Result<(), std::io::Error>{
let pressed_key = read_key()?;
match pressed_key {
- Key::Ctrl('q') => panic!("Program end"),
+ Key::Ctrl('q') => self.should_quit = true,
_ => ()
}
Ok(())
src/main.rs
1
2
3
4
5
6
7
8
@@ -5,6 +5,5 @@ mod editor;
use editor::Editor;

fn main() {
- let editor = Editor::default();
- editor.run();
+ Editor::default().run()
}

我们现在让should_quittrue时用break来终止循环,并在run函数中检查这一点。这使得程序退出的逻辑比之前清晰得多。

此外,我们还做了一些调整。在process_keypress()函数中,我们对self进行了修改,因此需要将参数从&self改为&mut self,以便能够修改引用。Rust对可变引用有严格的规则。

同样地,由于run函数内部需要调用process_keypress(),我们也调整了它的函数声明。

最后,在main函数中,let editor = ...表示editor是不可变的,我们不能在其上执行可能会修改它的run。为此我们将其改为let mut editor。现在我们不再需要editor变量,直接在default()的返回值上调用run()

清空屏幕

在之前,用户每次按键时,都会打印在屏幕上。现在让我们清空屏幕。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@@ -1,4 +1,4 @@
-use std::io::{self, stdout};
+use std::io::{self, stdout, Write};
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::IntoRawMode;
@@ -10,14 +10,17 @@ pub struct Editor {
impl Editor {
pub fn run(&mut self) {
let _stdout = stdout().into_raw_mode().unwrap();

loop {
- if let Err(error) = self.process_keypress() {
if let Err(error) = self.refresh_screen() {
die(error)
+ }
if self.should_quit {
break;
}
+ if let Err(error) = self.process_keypress() {
+ die(error)
+ }
}
}

@@ -25,11 +28,16 @@ impl Editor {
Self { should_quit: false }
}

+ fn refresh_screen(&self) -> Result<(), std::io::Error> {
+ print!("\x1b[2J");
+ io::stdout().flush()
+ }
+
fn process_keypress(&mut self) -> Result<(), std::io::Error> {
let pressed_key = read_key()?;
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
_ => (),
}
Ok(())
}

我们新增了一个名为refresh_screen的函数,在退出程序前调用。我们将process_keypress()移至较低的位置,这意味着**即使用户退出程序,我们仍会在最终退出前刷新一次屏幕。**这样做是为了后续能打印退出消息。

为了清屏,我们使用print函数向终端写入4个字节。第一个字节是\x1b,即转义字符,十进制下是27。其余三个字节是[2J

我们正在向终端写入一个转义序列。转义序列总是以转义字符(如Esc键产生的27)开始,后跟一个[字符。转义序列指示终端执行各种文本格式化任务,如改变文本颜色、移动光标或清除屏幕的部分内容。

我们使用J命令(Erase In Display)来清除屏幕。转义序列命令通常会带参数,参数位于命令之前。在这里参数是2,表示清除整个屏幕。\x1b[1J会清除光标处到屏幕顶部的内容,而\x1b[0J会清除光标处到屏幕末尾的内容。J命令默认的参数就是0,所以只写\x1b[J也能从光标处清屏到末尾。

向终端写入内容后,我们会调用flush(),它会强制stdout输出它所缓存的所有内容(有时它可能会缓存某些值而不是直接打印出来)。我们也返回了flush()的结果,与前面类似,要么是Ok表示没有值(即一切正常),要么是Err包装了一个错误(即刷新失败)。这一点很重要:如果我们在flush()之后添加了;`,我们就不会返回它的结果。

使用termion库让我们无需直接向终端写入转义序列,因此我们可以按照如下方式修改代码:

src/editor.rs
1
2
3
4
5
6
7
8
@@ -29,7 +29,7 @@ impl Editor {
}

fn refresh_screen(&self) -> Result<(), std::io::Error> {
- print!("\x1b[2J");
+ print!("{}",termion::clear::All);
io::stdout().flush()
}

现在,由于我们现在每次运行程序都会清屏,我们可能会错过编译器提供的有价值的提示。可以通过单独运行cargo build来查看这些警告。但请记住,如果你的代码没有变化,Rust不会重新编译你的代码,所以在执行cargo run之后立即运行cargo build并不会显示相同的警告。你可以先运行cargo clean然后再运行cargo build来重新编译整个项目,并查看所有的警告。

光标重定位

在前面的代码中,虽然执行了清屏的操作,但是光标依然会停留在以前的位置。我们需要将光标重新定位到左上角,以便准备从上到下绘制编辑器界面。

src/editor.rs
1
2
3
4
5
6
7
8
@@ -29,7 +29,7 @@ impl Editor {
}

fn refresh_screen(&self) -> Result<(), std::io::Error> {
- print!("{}",termion::clear::All);
+ print!("{}{}", termion::clear::All, termion::cursor::Goto(1, 1));
io::stdout().flush()
}

termion库中的转义序列termion::cursor::Goto使用H命令来定位光标,它实际上需要两个参数:行号和列号,来确定光标的新位置。因此,如果你的终端大小是80x24,并且你想要将光标置于屏幕中心,你可以使用\x1b[12;40H命令(多个参数之间用分号;分隔)。由于行和列的编号是从1开始的,而不是0,所以termion方法也是基于1的索引。

退出时清屏

当我们的程序崩溃时,就清屏并重新定位光标。如果在渲染屏幕的过程中发生错误,我们不希望屏幕上留下一堆乱码,也不希望错误信息就在光标当前的位置被打印出来。此外,如果用户决定退出,我们也会借此机会打印一条告别信息。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@@ -30,6 +30,9 @@ impl Editor {

fn refresh_screen(&self) -> Result<(), std::io::Error> {
print!("{}{}", termion::clear::All, termion::cursor::Goto(1, 1));
+ if self.should_quit {
+ println!("Goodbye.\r");
+ }
io::stdout().flush()
}

@@ -52,5 +55,6 @@ fn read_key() -> Result<Key, std::io::Error> {
}

fn die(e: std::io::Error) {
+ print!("{}", termion::clear::All);
panic!("{}", e);
}

##~

是时候开始绘制界面了。让我们在屏幕的左侧绘制一列波浪号~,就像vim编辑器那样。在我们的文本编辑器中,我们将在被编辑文件内容结束后的每一行的开头绘制一个波浪号。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@@ -32,6 +32,9 @@ impl Editor {
print!("{}{}", termion::clear::All, termion::cursor::Goto(1, 1));
if self.should_quit {
println!("Goodbye.\r");
+ } else {
+ self.draw_rows();
+ print!("{}", termion::cursor::Goto(1, 1));
}
io::stdout().flush()
}
@@ -44,6 +47,12 @@ impl Editor {
}
Ok(())
}
+
+ fn draw_rows(&self) {
+ for _ in 1..24 {
+ println!("~\r");
+ }
+ }
}

fn read_key() -> Result<Key, std::io::Error> {

draw_rows()函数负责绘制正在编辑的文本缓冲区的每一行。现在,它在每一行都绘制一个波浪符号,这表示**该行不是文件的一部分,也不会包含任何文本。**我们还不知道终端的大小,因此也不知道要绘制多少行。目前我们只绘制24行。在for _ in循环中,_表示我们不关心任何值,只是想重复执行某个操作多次。绘制完毕后,我们将光标重新定位到左上角。

窗口大小

我们接下来的目标是获取终端的尺寸,这样我们就知道在draw_rows()中需要绘制多少行。termion为我们提供了一个获取屏幕尺寸的方法,我们将使用这个方法在代表终端的新数据结构中。我们会将其放在一个名为terminal.rs的新文件中。

src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
@@ -1,9 +1,11 @@
#![allow(non_snake_case)]

mod editor;
+mod terminal;

use editor::Editor;
+pub use terminal::Terminal;

fn main() {
Editor::default().run()
}
src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@@ -1,3 +1,4 @@
+use crate::Terminal;
use std::io::{self, stdout, Write};
use termion::event::Key;
use termion::input::TermRead;
@@ -5,6 +6,7 @@ use termion::raw::IntoRawMode;

pub struct Editor {
should_quit: bool,
+ terminal: Terminal,
}

impl Editor {
@@ -25,7 +27,10 @@ impl Editor {
}

pub fn default() -> Self {
- Self { should_quit: false }
+ Self {
+ should_quit: false,
+ terminal: Terminal::default().expect("Failed to initialize terminal"),
+ }
}

fn refresh_screen(&self) -> Result<(), std::io::Error> {
@@ -49,7 +54,7 @@ impl Editor {
}

fn draw_rows(&self) {
- for _ in 1..24 {
+ for _ in 0..self.terminal.size().height {
println!("~\r");
}
}
src/terminal.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@@ -0,0 +1,24 @@
+pub struct Size {
+ pub width: u16,
+ pub height: u16,
+}
+
+pub struct Terminal {
+ size: Size,
+}
+
+impl Terminal {
+ pub fn default() -> Result<Self, std::io::Error> {
+ let size = termion::terminal_size()?;
+ Ok(Self {
+ size: Size {
+ width: size.0,
+ height: size.1,
+ },
+ })
+ }
+
+ pub fn size(&self) -> &Size {
+ &self.size
+ }
+}

terminal.rs中,我们定义了Terminal和一个辅助结构体Size。在default中,我们获取了termionterminal_size,并将其转换为Size,然后返回Terminal的新实例。为了处理潜在的错误,我们将其包装在Ok中。我们也不想让外部调用者修改终端尺寸。因此,我们没有用pub标记size为公开的。相反,我们添加了一个名为size的方法,它返回对内部size的只读引用。Size.widthheight都是u16s,是一个无符号的16位整数,大约在65,000左右结束。这对于终端来说很够用了。

现在对于新的结构体,让我们研究一下它是如何从编辑器中被引用的。首先,我们在main.rs中引入我们的新结构体,并在Terminal结构体前添加了pub使其公开。这样做有什么作用呢?–在editor.rs中,我们现在可以使用use crate::Terminal来导入终端。如果没有在main.rs中使用pub use语句,我们就不能这样做,相反我们需要使用use crate::terminal::Terminal。实质上,我们在顶层导出了Terminal结构体,并通过crate::Terminal使其可达。

在我们的编辑器结构体中,我们在初始化default()时添加了对我们终端的引用。注意Terminal::default返回一个Terminal实例或一个错误。我们用expect解包Terminal,如果我们有一个值,我们返回它。如果我们没有一个值,我们就会带着传递给expect的文本引发painc。在这里我们不需要die,因为die主要在我们重复绘制屏幕时才有用。

现在我们把与Terminal相关的代码移到新文件中:

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@@ -1,8 +1,5 @@
use crate::Terminal;
-use std::io::{self, stdout, Write};
use termion::event::Key;
-use termion::input::TermRead;
-use termion::raw::IntoRawMode;

pub struct Editor {
should_quit: bool,
@@ -11,8 +8,6 @@ pub struct Editor {

impl Editor {
pub fn run(&mut self) {
- let _stdout = stdout().into_raw_mode().unwrap();
-
loop {
if let Err(error) = self.refresh_screen() {
die(error)
@@ -34,18 +29,19 @@ impl Editor {
}

fn refresh_screen(&self) -> Result<(), std::io::Error> {
- print!("{}{}", termion::clear::All, termion::cursor::Goto(1, 1));
+ Terminal::clear_screen();
+ Terminal::cursor_position(0, 0);
if self.should_quit {
println!("Goodbye.\r");
} else {
self.draw_rows();
- print!("{}", termion::cursor::Goto(1, 1));
+ Terminal::cursor_position(0, 0);
}
- io::stdout().flush()
+ Terminal::flush()
}

fn process_keypress(&mut self) -> Result<(), std::io::Error> {
- let pressed_key = read_key()?;
+ let pressed_key = Terminal::read_key()?;
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
_ => (),
@@ -60,15 +56,7 @@ impl Editor {
}
}

-fn read_key() -> Result<Key, std::io::Error> {
- loop {
- if let Some(key) = io::stdin().lock().keys().next() {
- return key;
- }
- }
-}
-
fn die(e: std::io::Error) {
- print!("{}", termion::clear::All);
+ Terminal::clear_screen();
panic!("{}", e);
}
src/terminal.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@@ -1,3 +1,8 @@
+use std::io::{self, stdout, Write};
+use termion::event::Key;
+use termion::input::TermRead;
+use termion::raw::{IntoRawMode, RawTerminal};
+
pub struct Size {
pub width: u16,
pub height: u16,
@@ -5,6 +10,7 @@ pub struct Size {

pub struct Terminal {
size: Size,
+ _stdout: RawTerminal<std::io::Stdout>,
}

impl Terminal {
@@ -15,10 +21,33 @@ impl Terminal {
width: size.0,
height: size.1,
},
+ _stdout: stdout().into_raw_mode()?,
})
}

pub fn size(&self) -> &Size {
&self.size
}
+
+ pub fn clear_screen() {
+ print!("{}", termion::clear::All);
+ }
+
+ pub fn cursor_position(x: u16, y: u16) {
+ let x = x.saturating_add(1);
+ let y = y.saturating_add(1);
+ print!("{}", termion::cursor::Goto(x, y));
+ }
+
+ pub fn flush() -> Result<(), std::io::Error> {
+ io::stdout().flush()
+ }
+
+ pub fn read_key() -> Result<Key, std::io::Error> {
+ loop {
+ if let Some(key) = io::stdin().lock().keys().next() {
+ return key;
+ }
+ }
+ }
}

我们将所有低级终端相关的内容移到了Terminal中,把高级内容留在了editor.rs

  • 不需要在编辑器的原始模式中跟踪stdout了。这现在由Terminal内部处理——只要Terminal结构体存在,_stdout就会存在。
  • 通过使Terminal::cursor_position基于0隐藏了终端是基于1的事实。
  • 防止u16类型的cursor_position发生溢出。

关于溢出,我们的类型有它们可以容纳的最大大小。如前所述,对于u16,这个限制大约在65,000。那么如果你在最大值上+1会发生什么?它会变成可能的最小值,也就是对于无符号类型来说是0!这被称为溢出。在Rust中处理溢出的正常方式如下:在调试模式(默认情况下我们使用的模式)中,程序会崩溃。这是我们所期望的:**编译器不应该试图让你的程序保持运行,而应该明显地显示出问题。**在生产模式下,会发生溢出,这也是我们所期望的,**因为我们不希望应用程序在生产中意外崩溃,它可以继续使用溢出值。**在我们的程序中我们使用saturating_add尝试+1,否则就返回最大值。

最后一行

你可能注意到屏幕的最后一行似乎没有波浪符号。这是代码中的一个小错误。当我们打印最后一个波浪符号时,我们接着打印了一个"\r\n",就像打印其他任何一行一样,但这会导致终端滚动以腾出空间给一个新的空白行。由于我们稍后想在底部有一个状态栏,现在让我们先改变我们绘制行的范围。

src/editor.rs
1
2
3
4
5
6
7
@@ -50,7 +50,7 @@ impl Editor {
}

fn draw_rows(&self) {
- for _ in 0..self.terminal.size().height {
+ for _ in 0..self.terminal.size().height - 1 {
println!("~\r");

绘制时隐藏光标

还有一个问题:光标可能会在终端绘制到屏幕上的那一刻短暂地显示在屏幕中间的某个地方。为了确保这种情况不会发生,我们在刷新屏幕之前隐藏光标,并在刷新结束后立即显示它。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@@ -29,6 +29,7 @@ impl Editor {
}

fn refresh_screen(&self) -> Result<(), std::io::Error> {
+ Terminal::cursor_hide();
Terminal::clear_screen();
Terminal::cursor_position(0, 0);
if self.should_quit {
@@ -37,6 +38,7 @@ impl Editor {
self.draw_rows();
Terminal::cursor_position(0, 0);
}
+ Terminal::cursor_show();
Terminal::flush()
}
src/terminal.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
@@ -50,4 +50,12 @@ impl Terminal {
}
}
}
+
+ pub fn cursor_hide() {
+ print!("{}", termion::cursor::Hide);
+ }
+
+ pub fn cursor_show() {
+ print!("{}", termion::cursor::Show);
+ }
}

逐行清除

与其在每次刷新前清除整个屏幕,不如在我们重绘每一行时清除该行,这似乎更为理想。让我们用\x1b[K序列(在我们绘制的每一行开头)替换termion::clear::All(清除整个屏幕)转义序列,并结合使用termion::clear::CurrentLine进行绘制。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@@ -33,6 +33,7 @@ impl Editor {
Terminal::clear_screen();
Terminal::cursor_position(0, 0);
if self.should_quit {
+ Terminal::clear_screen();
println!("Goodbye.\r");
} else {
self.draw_rows();
@@ -53,6 +54,7 @@ impl Editor {

fn draw_rows(&self) {
for _ in 0..self.terminal.size().height - 1 {
+ Terminal::clear_current_line();
println!("~\r");
src/terminal.rs
1
2
3
4
5
6
7
8
9
@@ -58,4 +58,8 @@ impl Terminal {
pub fn cursor_show() {
print!("{}", termion::cursor::Show);
}
+
+ pub fn clear_current_line() {
+ print!("{}", termion::clear::CurrentLine);
+ }
}

需要注意的是,在显示告别信息之前也会先清除屏幕,以避免在程序最终结束前,告别信息覆盖在其他行上方。

欢迎信息

是时候显示一个欢迎信息了。在程序启动时,让在屏幕下方三分之一的位置显示我们编辑器的名称和版本号。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@@ -1,6 +1,7 @@
use crate::Terminal;
use termion::event::Key;

+const VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct Editor {
should_quit: bool,
terminal: Terminal,
@@ -53,9 +54,14 @@ impl Editor {
}

fn draw_rows(&self) {
- for _ in 0..self.terminal.size().height - 1 {
+ let height = self.terminal.size().height;
+ for row in 0..height - 1 {
Terminal::clear_current_line();
- println!("~\r");
+ if row == height / 3 {
+ println!("iTEditor -- version {}\r", VERSION)
+ } else {
+ println!("~\r")
+ }
}
}
}

我们在代码中添加了一个名为VERSION的常量。由于Cargo.toml文件已经包含了版本号,我们使用env!宏来检索它,并将其加入到我们的Welcome Message中。

然而,我们需要处理一个问题:由于终端尺寸的限制,我们的信息可能会被截断。现在我们就来解决这个问题。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
@@ -58,7 +58,10 @@ impl Editor {
for row in 0..height - 1 {
Terminal::clear_current_line();
if row == height / 3 {
- println!("iTEditor -- version {}\r", VERSION)
+ let welcome_messgae = format!("iTEditor -- version {}\r", VERSION);
+ let width =
+ std::cmp::min(self.terminal.size().width as usize, welcome_messgae.len());
+ println!("{}\r", &welcome_messgae[..width])
} else {
println!("~\r")
}

[...width]意味着我们想要从字符串的开始进行切片,直到width为止,这里width被计算为屏幕宽度与欢迎信息长度的较小值,这确保了我们不会切除比已有内容更多的字符串部分。

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

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@@ -1,3 +1,5 @@
+use std::fmt::format;
+
use crate::Terminal;
use termion::event::Key;

@@ -58,15 +60,23 @@ impl Editor {
for row in 0..height - 1 {
Terminal::clear_current_line();
if row == height / 3 {
- let welcome_messgae = format!("iTEditor -- version {}\r", VERSION);
- let width =
- std::cmp::min(self.terminal.size().width as usize, welcome_messgae.len());
- println!("{}\r", &welcome_messgae[..width])
+ self.draw_welcome_message();
} else {
println!("~\r")
}
}
}
+
+ fn draw_welcome_message(&self) {
+ let mut welcome_message = format!("iTEditor -- version {}\r", VERSION);
+ let width = self.terminal.size().width as usize;
+ let len = welcome_message.len();
+ let padding = width.saturating_sub(len) / 2;
+ let spaces = " ".repeat(padding.saturating_sub(1));
+ welcome_message = format!("~{}{}", spaces, welcome_message);
+ welcome_message.truncate(width);
+ println!("{}\r", welcome_message);
+ }
}

要让一段文本居中显示,需要将屏幕宽度/2,然后从中减去字符串长度的一半。换句话说:(width/2) - (welcome_len/2),简化后为(width - welcome_len) / 2。这告诉你从屏幕左边缘开始打印字符串应该离开多远。因此我们用空格字符填充这段空间,除了第一个字符,应该是波浪符号。repeat是一个很好的辅助函数,它重复我们传递给它的字符,而truncate在必要时将字符串缩短到特定宽度。

移动光标

现在让我们编写输入部分,我们希望用户能够移动光标。第一步是在编辑器状态中跟踪光标的xy位置。为此,我们将添加另一个结构体来帮助我们。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@@ -1,12 +1,16 @@
-use std::fmt::format;
-
use crate::Terminal;
use termion::event::Key;

const VERSION: &str = env!("CARGO_PKG_VERSION");
+
+struct Position {
+ x: usize,
+ y: usize,
+}
pub struct Editor {
should_quit: bool,
terminal: Terminal,
+ cursor_position: Position,
}

impl Editor {
@@ -28,6 +32,7 @@ impl Editor {
Self {
should_quit: false,
terminal: Terminal::default().expect("Failed to initialize terminal"),
+ cursor_position: Position { x: 0, y: 0 },
}
}

cursor_position结构体负责追踪光标在编辑文档中的水平和垂直坐标,其起始点设为屏幕的左上角(0,0)。不过,它不是作为Terminal结构体的一部分来处理的,因为它代表的是光标在文档中的位置,而不是在终端界面上的位置。此外,尽管终端尺寸采用u16类型,可能仅支持到65,000行,但对于光标位置,我们选择了usize类型,因为它根据编译的目标架构的不同,可以是32位64位,这样可以支持更大的文档。

现在,我们要给refresh_screen()函数增加代码,使其能够将光标移动到cursor_position中储存的位置。同时,我们也将重新编写cursor_position,使其能接受一个Position类型的参数。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@@ -3,9 +3,9 @@ use termion::event::Key;

const VERSION: &str = env!("CARGO_PKG_VERSION");

-struct Position {
- x: usize,
- y: usize,
+pub struct Position {
+ pub x: usize,
+ pub y: usize,
}
pub struct Editor {
should_quit: bool,
@@ -39,13 +39,13 @@ impl Editor {
fn refresh_screen(&self) -> Result<(), std::io::Error> {
Terminal::cursor_hide();
Terminal::clear_screen();
- Terminal::cursor_position(0, 0);
+ Terminal::cursor_position(&Position { x: 0, y: 0 });
if self.should_quit {
Terminal::clear_screen();
println!("Goodbye.\r");
} else {
self.draw_rows();
- Terminal::cursor_position(0, 0);
+ Terminal::cursor_position(&self.cursor_position);
}
Terminal::cursor_show();
Terminal::flush()
scr/main.rs
1
2
3
4
5
6
7
8
@@ -5,6 +5,7 @@ mod terminal;

use editor::Editor;
pub use terminal::Terminal;
+pub use editor::Position;

fn main() {
Editor::default().run()
src/terminal.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@@ -3,6 +3,8 @@ use termion::event::Key;
use termion::input::TermRead;
use termion::raw::{IntoRawMode, RawTerminal};

+use crate::editor::Position;
+
pub struct Size {
pub width: u16,
pub height: u16,
@@ -33,9 +35,12 @@ impl Terminal {
print!("{}", termion::clear::All);
}

- pub fn cursor_position(x: u16, y: u16) {
+ pub fn cursor_position(position: &Position) {
+ let Position { mut x, mut y } = position;
let x = x.saturating_add(1);
let y = y.saturating_add(1);
+ let x = x as u16;
+ let y = y as u16;
print!("{}", termion::cursor::Goto(x, y));
}

我们正在使用解构(destructuring)来初始化cursor_position中的xylet Position{mut x, mut y} = position; 这会创建新的变量xy,并将它们绑定到position中同名的字段。

我们还将在Position中的usize数据类型转换为u16。如果u16无法容纳足够大的值,那么值将被截断。目前这样做没问题——我们稍后会添加逻辑以确保我们始终在u16的边界内——因此我们在这里添加了一个小指令,以避免Clippy因此类错误而打扰我们。

提到Clippy的烦人警告,我们目前有一个旧警告需要解决。接下来,我们将允许用户使用箭头键移动光标。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@@ -55,11 +57,24 @@ impl Editor {
let pressed_key = Terminal::read_key()?;
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
+ Key::Up | Key::Down | Key::Left | Key::Right => self.move_cursor(pressed_key),
_ => (),
}
Ok(())
}

+ fn move_cursor(&mut self, key: Key) {
+ let Position { mut x, mut y } = self.cursor_position;
+ match key {
+ Key::Up => y = y.saturating_sub(1),
+ Key::Down => y = y.saturating_add(1),
+ Key::Left => x = x.saturating_sub(1),
+ Key::Right => x = x.saturating_add(1),
+ _ => (),
+ }
+ self.cursor_position = Position { x, y }
+ }
+
fn draw_rows(&self) {
let height = self.terminal.size().height;
for row in 0..height - 1 {

现在我们可以通过方向键来移动光标了。

防止光标移出屏幕

目前,光标位置cursor_position的值可能会超出屏幕的右侧和底部边界。我们可以通过在move_cursor()函数中进行边界检查来预防这一问题。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@@ -65,11 +63,22 @@ impl Editor {

fn move_cursor(&mut self, key: Key) {
let Position { mut x, mut y } = self.cursor_position;
+ let size = self.terminal.size();
+ let height = size.height.saturating_sub(1) as usize;
+ let width = size.width.saturating_sub(1) as usize;
match key {
Key::Up => y = y.saturating_sub(1),
- Key::Down => y = y.saturating_add(1),
+ Key::Down => {
+ if y < height {
+ y = y.saturating_add(1);
+ }
+ }
Key::Left => x = x.saturating_sub(1),
- Key::Right => x = x.saturating_add(1),
+ Key::Right => {
+ if x < width {
+ x = x.saturating_add(1);
+ }
+ }
_ => (),
}
self.cursor_position = Position { x, y }

你应该能够确认现在可以在可见区域内移动光标,并且光标会保持在终端的边界内。你还可以将光标放在最后一行,尽管那里目前还没有~

映射功能键

为了完善我们的低级终端代码,我们需要检测一些更特殊的按键操作。我们将映射Page UpPage DownHomeEnd键,以便分别将光标定位到屏幕的顶部或底部,或是行的开头或末尾。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@@ -55,7 +55,14 @@ impl Editor {
let pressed_key = Terminal::read_key()?;
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
- Key::Up | Key::Down | Key::Left | Key::Right => self.move_cursor(pressed_key),
+ Key::Up
+ | Key::Down
+ | Key::Left
+ | Key::Right
+ | Key::Home
+ | Key::End
+ | Key::PageUp
+ | Key::PageDown => self.move_cursor(pressed_key),
_ => (),
}
Ok(())
@@ -79,6 +86,10 @@ impl Editor {
x = x.saturating_add(1);
}
}
+ Key::Home => x = 0,
+ Key::End => x = width,
+ Key::PageUp => y = 0,
+ Key::PageDown => y = height,
_ => (),
}
self.cursor_position = Position { x, y }

本文链接: https://zone.ivanz.cc/p/156e55dd.html


Rust学习-制作一个文本编辑器: Raw Input And Output
https://zone.ivanz.cc/p/156e55dd
作者
Ivan Zhang
发布于
2023年11月15日
更新于
2024年1月10日
许可协议