Rust学习-制作一个文本编辑器: A Text Editor

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

插入普通字符

现在我们编写一个insert函数,在给定位置向一个Document中插入单个字符。我们先从允许在字符串的给定位置添加一个字符开始。

src/document.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@@ -1,4 +1,5 @@
use crate::Row;
+use crate::Position;
use std::fs;

#[derive(Default)]
@@ -28,4 +29,15 @@ impl Document {
pub fn len(&self) -> usize {
self.rows.len()
}
+
+ pub fn insert(&mut self, at: &Position, c: char) {
+ if at.y == self.len() {
+ let mut row = Row::default();
+ row.insert(0, c);
+ self.rows.push(row);
+ } else if at.y < self.len() {
+ let row = self.rows.get_mut(at.y).unwrap();
+ row.insert(at.x, c);
+ }
+ }
}
src/editor.rs
1
2
3
4
5
6
7
8
@@ -104,6 +104,7 @@ impl Editor {
let pressed_key = Terminal::read_key()?;
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
+ Key::Char(c) => self.document.insert(&self.cursor_position, c),
Key::Up
| Key::Down
| Key::Left
src/row.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,6 +1,7 @@
use std::cmp;
use unicode_segmentation::UnicodeSegmentation;

+#[derive(Default)]
pub struct Row {
string: String,
len: usize,
@@ -44,4 +45,17 @@ impl Row {
pub fn update_len(&mut self) {
self.len = self.string[..].graphemes(true).count();
}
+
+ pub fn insert(&mut self, at: usize, c: char) {
+ if at >= self.len() {
+ self.string.push(c);
+ } else {
+ let mut resule: String = self.string[..].graphemes(true).take(at).collect();
+ let remainder: String = self.string[..].graphemes(true).skip(at).collect();
+ resule.push(c);
+ resule.push_str(&remainder);
+ self.string = resule;
+ }
+ self.update_len();
+ }
}

Row中。我们分为两种情况进行处理:如果我们恰好位于字符串的末尾,就将字符追加入其中。这可能发生在用户位于一行的末尾并且继续输入时。反之,我们通过逐个字符遍历来重建我们的字符串。

我们使用迭代器的takeskip函数来创建新的迭代器,一个从0开始到at(包括at),另一个从at之后的元素开始到末尾。我们使用collect来将这些迭代器合并成字符串。由于collect可以创建多种集合,我们必须提供resultremainder的类型,否则Rust不会知道要创建什么类型的集合。

与我们在Row中做的类似,我们正在处理用户试图在文档底部插入的情况,这种情况下我们将创建一个新行。

现在我们需要在输入字符时调用该方法。我们通过扩展editor中的process_keypress来做到这一点。

有了这个更改,我们现在可以在文档的任何地方添加字符。但我们的光标不会移动—所以我们实际上是在反向输入文字。让我们通过将“输入一个字符”视为“在光标右侧输入一个字符”来解决这个问题。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
@@ -104,7 +104,10 @@ impl Editor {
let pressed_key = Terminal::read_key()?;
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
- Key::Char(c) => self.document.insert(&self.cursor_position, c),
+ Key::Char(c) => {
+ self.document.insert(&self.cursor_position, c);
+ self.move_cursor(Key::Right);
+ }
Key::Up
| Key::Down
| Key::Left

行内删除

我们现在想实现BackspaceDelete的功能。先从Delete键开始,它应该删除光标下的字符。如果你的光标是一个线条|,而不是一个块▪️,“光标下” 意味着 “光标前面”,因为光标是一个在其位置左侧显示的闪烁线。让我们添加一个delete函数。

src/document.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@@ -40,4 +41,13 @@ impl Document {
row.insert(at.x, c);
}
}
+
+ pub fn delete(&mut self, at: &Position) {
+ if at.y >= self.len() {
+ return;
+ } else {
+ let row = self.rows.get_mut(at.y).unwrap();
+ row.delete(at.x);
+ }
+ }
}
src/editor.rs
1
2
3
4
5
6
7
8
@@ -108,6 +108,7 @@ impl Editor {
self.document.insert(&self.cursor_position, c);
self.move_cursor(Key::Right);
}
+ Key::Delete => self.document.delete(&self.cursor_position),
Key::Up
| Key::Down
| Key::Left
src/row.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@@ -58,4 +58,16 @@ impl Row {
}
self.update_len();
}
+
+ pub fn delete(&mut self, at: usize) {
+ if at >= self.len() {
+ return;
+ } else {
+ let mut result: String = self.string[..].graphemes(true).take(at).collect();
+ let remainder: String = self.string[..].graphemes(true).skip(at + 1).collect();
+ result.push_str(&remainder);
+ self.string = result;
+ }
+ self.update_len();
+ }
}

这段代码和我们之前编写的insert代码非常相似。不同之处在于,在Row中,我们不是在添加一个字符,而是在合并结果result和剩余部分remainder时跳过我们想要删除的那个字符。在Document中,我们目前还没有实现删除一行的情况。

接下来让我们处理Backspace。从本质上讲,Backspace向左移动和删除的结合,因此我们按如下方式调整process_keypress

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
@@ -109,6 +109,12 @@ impl Editor {
self.move_cursor(Key::Right);
}
Key::Delete => self.document.delete(&self.cursor_position),
+ Key::Backspace => {
+ if self.cursor_position.x > 0 || self.cursor_position.y > 0 {
+ self.move_cursor(Key::Left);
+ self.document.delete(&self.cursor_position);
+ }
+ }
Key::Up
| Key::Down
| Key::Left

Backspace现在可以在行内工作了。我们还确保了如果我们位于文档的开头,不会执行删除操作,否则,我们将开始删除光标左侧的元素。

复杂删除

目前我们有两个特殊情况尚未处理:一个是在行的开头使用Backspace,另一个是在行的末尾使用Delete。在我们的实现中,Backspace简单地向左移动,这在行的开头意味着移动到前一行的末尾,然后尝试删除一个字符。这意味着,一旦我们允许在行末进行删除操作,Backspace的情况就会得到解决。

src/document.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@@ -42,8 +42,14 @@ impl Document {
}

pub fn delete(&mut self, at: &Position) {
- if at.y >= self.len() {
+ let len = self.len();
+ if at.y >= len {
return;
+ }
+ if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y < len - 1 {
+ let next_row = self.rows.remove(at.y + 1);
+ let row = self.rows.get_mut(at.y).unwrap();
+ row.append(&next_row);
} else {
let row = self.rows.get_mut(at.y).unwrap();
row.delete(at.x);
src/row.rs
1
2
3
4
5
6
7
8
9
10
@@ -70,4 +70,9 @@ impl Row {
}
self.update_len();
}
+
+ pub fn append(&mut self, new_row: &Self) {
+ self.string = format!("{}{}", self.string, new_row.string);
+ self.update_len();
+ }
}

我们在Row中增加了一个新方法append,可以把另一个row的内容追加到末尾。我们在Document中使用这个功能。现在,Document中的代码看起来有点复杂。它所做的是检查我们是否位于一行的末尾,以及是否有另一行跟在这一行之后。如果是这样,我们就从我们的vec中移除下一行并将其追加到当前行。反之,我们就尝试从当前行中删除。

那么,为什么代码看起来这么复杂呢?我们能否简单地将row的定义移动到 if 语句之上,以使事情更清楚些?

由于Rust租用的概念,我们不能同时对向量内的元素持有两个可变引用,也不能在对向量内的一个元素持有可变引用时修改向量。为什么?因为比方说我们有一个包含[A, B, C]的向量,我们持有一个指向B内存位置的引用。现在我们移除A,导致BC向左移动。我们的引用会突然不再指向B,而是指向C
这意味着我们不能持有对row的引用然后删除向量的一部分。所以我们首先直接读取row的长度,而不保留引用。然后我们通过从中移除一个元素来改变向量,接着我们创建对row的可变引用。

ENTER键

最后我们来实现EnterEnter键允许用户在文本中插入新行或将一行分割成两行。你现在确实可以通过这种方式添加新行,但正如你可能预料的,处理方式并不理想。这是因为新行被作为行的一部分插入,而不是创建新的行。

让我们从一个简单的情况开始,即在当前行下方添加一个新行。

src/document.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
@@ -30,7 +30,23 @@ impl Document {
self.rows.len()
}

+ fn insert_new_line(&mut self, at: &Position) {
+ if at.y > self.len() {
+ return;
+ }
+ let new_row = Row::default();
+ if at.y == self.len() || at.y.saturating_add(1) == self.len() {
+ self.rows.push(new_row);
+ } else {
+ self.rows.insert(at.y + 1, new_row);
+ }
+ }
+
pub fn insert(&mut self, at: &Position, c: char) {
+ if c == '\n' {
+ self.insert_new_line(at);
+ return;
+ }
if at.y == self.len() {
let mut row = Row::default();
row.insert(0, c);

我们在有新行传入时从insert调用insert_new_line函数。在insert_new_line中,我们会检查Enter键是否在文档的最后一行或其下一行被按下。如果是这种情况,我们会在我们的vec的末尾推入一个新行。如果不是这种情况,我们会在正确的位置插入一个新行。

src/document.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@@ -34,10 +34,11 @@ impl Document {
if at.y > self.len() {
return;
}
- let new_row = Row::default();
- if at.y == self.len() || at.y.saturating_add(1) == self.len() {
- self.rows.push(new_row);
+ if at.y == self.len() {
+ self.rows.push(Row::default());
+ return;
} else {
+ let new_row = self.rows.get_mut(at.y).unwrap().split(at.x);
self.rows.insert(at.y + 1, new_row);
}
}
src/row.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
@@ -75,4 +75,12 @@ impl Row {
self.string = format!("{}{}", self.string, new_row.string);
self.update_len();
}
+
+ pub fn split(&mut self, at: usize) -> Self {
+ let beginning: String = self.string[..].graphemes(true).take(at).collect();
+ let remainder: String = self.string[..].graphemes(true).skip(at).collect();
+ self.string = beginning;
+ self.update_len();
+ Self::from(&remainder[..])
+ }
}

我们现在添加了一个叫做split的方法,它会截断当前行直到给定的索引,并返回包含该索引之后所有内容的另一行。在Document上,如果我们位于最后一行之下,我们现在只推入一个空行;在其他任何情况下,我们都是用split改变当前行并插入结果。即使在一行的末尾,这也是有效的,在这种情况下新行将只包含一个空字符串。

现在我们可以在文档中自由移动,添加空白、字符,甚至是表情符号,删除行等等。

保存文件

现在我们终于使文本变得可编辑了,让我们实现将其保存到磁盘上的功能。我们从在Document中实现一个save方法开始。

src/document.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
@@ -1,6 +1,7 @@
use crate::Position;
use crate::Row;
use std::fs;
+use std::io::{Error, Write};

#[derive(Default)]
pub struct Document {
@@ -72,4 +73,15 @@ impl Document {
row.delete(at.x);
}
}
+
+ pub fn save(&self) -> Result<(), Error> {
+ if let Some(file_name) = &self.file_name {
+ let mut file = fs::File::create(file_name)?;
+ for row in &self.rows {
+ file.write_all(row.as_bytes())?;
+ file.write_all(b"\n")?;
+ }
+ }
+ Ok(())
+ }
}
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
@@ -56,7 +56,7 @@ impl Editor {

pub fn default() -> Self {
let args: Vec<String> = env::args().collect();
- let mut initial_status = String::from("HELP: Ctrl-Q = quit");
+ let mut initial_status = String::from("HELP: Ctrl-S = save | Ctrl-Q = quit");
let document = if args.len() > 1 {
let file_name = &args[1];
let doc = Document::open(&file_name);
@@ -104,6 +104,14 @@ impl Editor {
let pressed_key = Terminal::read_key()?;
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
+ Key::Ctrl('s') => {
+ if self.document.save().is_ok() {
+ self.status_message =
+ StatusMessage::form("File saved successfully".to_string());
+ } else {
+ self.status_message = StatusMessage::form("Error saving file".to_string());
+ }
+ }
Key::Char(c) => {
self.document.insert(&self.cursor_position, c);
self.move_cursor(Key::Right);
src/row.rs
1
2
3
4
5
6
7
8
9
@@ -83,4 +83,8 @@ impl Row {
self.update_len();
Self::from(&remainder[..])
}
+
+ pub fn as_bytes(&self) -> &[u8] {
+ self.string.as_bytes()
+ }
}

我们扩展了Row,增加了一个方法,允许我们将行转换为字节数组。在Document中,write_all方法接收这个字节数组并将其写入到磁盘。由于我们的行不包含新行的标志,我们需要单独写出它。b前缀表示这是一个字节数组,而不是字符串。

由于写入可能会引发错误,我们的save函数返回一个Result类型,并且我们使用?操作符来传递可能发生的任何错误给调用者。

Document中,我们将save方法与Ctrl-S绑定。我们通过使用is_ok方法来检查写入是否成功,它在ResultOk而不是Err时返回true,然后我们相应地设置状态消息。

另存为…

当前,当用户不带任何参数运行iTEditor时,会得到一个空白文件进行编辑,但没有办法保存。我先现在创建一个prompt()方法,它可以在状态栏显示一个提示符,并允许用户在该提示符后输入一行文本。

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
@@ -105,6 +105,9 @@ impl Editor {
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
Key::Ctrl('s') => {
+ if self.document.file_name.is_none() {
+ self.document.file_name = Some(self.prompt("Save as: ")?);
+ }
if self.document.save().is_ok() {
self.status_message =
StatusMessage::form("File saved successfully".to_string());
@@ -286,6 +289,24 @@ impl Editor {
print!("{}", text);
}
}
+
+ fn prompt(&mut self, prompt: &str) -> Result<String, std::io::Error> {
+ let mut result = String::new();
+ loop {
+ self.status_message = StatusMessage::form(format!("{}{}", prompt, result));
+ self.refresh_screen()?;
+ if let Key::Char(c) = Terminal::read_key()? {
+ if c == '\n' {
+ self.status_message = StatusMessage::form(String::new());
+ break;
+ }
+ if !c.is_control() {
+ result.push(c);
+ }
+ }
+ }
+ Ok(result)
+ }
}

用户的输入会被存储在一个名为result的变量中,我们将其初始化为一个空字符串。接着,程序将进入一个无限循环,循环中不断设置状态消息,刷新屏幕,并等待处理按键输入。当用户按下Enter时,状态消息会被清除,并返回输入的消息。途中可能出现的错误会被向上传播。

现在用户可以保存文件了,我们在提示功能中再处理几个情况。现在让我们允许用户通过按Esc和使用Backspace,同时也将空输入视为取消操作。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@@ -100,21 +100,28 @@ impl Editor {
Terminal::flush()
}

+ fn save(&mut self) {
+ if self.document.file_name.is_none() {
+ let new_name = self.prompt("Save as: ").unwrap_or(None);
+ if new_name.is_none() {
+ self.status_message = StatusMessage::form("Save aborted".to_string());
+ return;
+ }
+ self.document.file_name = new_name;
+ }
+
+ if self.document.save().is_ok() {
+ self.status_message = StatusMessage::form("File saved successfully.".to_string());
+ } else {
+ self.status_message = StatusMessage::form("Error writing file!".to_string());
+ }
+ }
+
fn process_keypress(&mut self) -> Result<(), std::io::Error> {
let pressed_key = Terminal::read_key()?;
match pressed_key {
Key::Ctrl('q') => self.should_quit = true,
- Key::Ctrl('s') => {
- if self.document.file_name.is_none() {
- self.document.file_name = Some(self.prompt("Save as: ")?);
- }
- if self.document.save().is_ok() {
- self.status_message =
- StatusMessage::form("File saved successfully".to_string());
- } else {
- self.status_message = StatusMessage::form("Error saving file".to_string());
- }
- }
+ Key::Ctrl('s') => self.save(),
Key::Char(c) => {
self.document.insert(&self.cursor_position, c);
self.move_cursor(Key::Right);
@@ -290,22 +297,35 @@ impl Editor {
}
}

- fn prompt(&mut self, prompt: &str) -> Result<String, std::io::Error> {
+ fn prompt(&mut self, prompt: &str) -> Result<Option<String>, std::io::Error> {
let mut result = String::new();
loop {
self.status_message = StatusMessage::form(format!("{}{}", prompt, result));
self.refresh_screen()?;
- if let Key::Char(c) = Terminal::read_key()? {
- if c == '\n' {
- self.status_message = StatusMessage::form(String::new());
- break;
+ match Terminal::read_key()? {
+ Key::Backspace => {
+ if !result.is_empty() {
+ result.truncate(result.len() - 1);
+ }
}
- if !c.is_control() {
- result.push(c);
+ Key::Char('\n') => break,
+ Key::Char(c) => {
+ if !c.is_control() {
+ result.push(c);
+ }
+ }
+ Key::Esc => {
+ result.truncate(0);
+ break;
}
+ _ => (),
}
}
- Ok(result)
+ self.status_message = StatusMessage::form(String::new());
+ if result.is_empty() {
+ return Ok(None);
+ }
+ Ok(Some(result))
}
}

现在的prompt不仅包含了一个Result类型,还包含了一个Option类型。这样做的想法是,即使提示操作成功,它也可以返回None,表示用户已经中断了提示。我们用match替换了原来的if语句,以便也能处理退格和Esc情况。在Esc的情况下,我们将重置所有之前输入的文本,并退出循环。在退格的情况下,我们将逐个减少输入,移除正在进行中的最后一个字符。

最后,我们在process_keypress外部创建了一个save函数。在这个函数中,如果提示返回了None或者返回了错误,我们现在都将中断保存操作。

修改标志

我们希望跟踪我们编辑器中加载的文本与文件中的内容是否有差异。然后我们可以警告用户,当他们尝试退出时可能会丢失未保存的更改。

如果文档自打开或保存文件以来已被修改,我们称之为dirty文档。让我们在Document中添加一个dirty变量并将其初始化为false。我们不希望这个变量从外部被修改,因此我们添加了一个只读的is_dirty函数到Document。我们还会在任何文本更改时将其设置为true,并在保存时设置为false

src/document.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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@@ -7,6 +7,7 @@ use std::io::{Error, Write};
pub struct Document {
rows: Vec<Row>,
pub file_name: Option<String>,
+ dirty: bool,
}

impl Document {
@@ -19,6 +20,7 @@ impl Document {
Ok(Self {
rows,
file_name: Some(filename.to_string()),
+ dirty: false,
})
}
pub fn row(&self, index: usize) -> Option<&Row> {
@@ -32,9 +34,6 @@ impl Document {
}

fn insert_new_line(&mut self, at: &Position) {
- if at.y > self.len() {
- return;
- }
if at.y == self.len() {
self.rows.push(Row::default());
return;
@@ -45,6 +44,10 @@ impl Document {
}

pub fn insert(&mut self, at: &Position, c: char) {
+ if at.y > self.len() {
+ return;
+ }
+ self.dirty = true;
if c == '\n' {
self.insert_new_line(at);
return;
@@ -53,7 +56,7 @@ impl Document {
let mut row = Row::default();
row.insert(0, c);
self.rows.push(row);
- } else if at.y < self.len() {
+ } else {
let row = self.rows.get_mut(at.y).unwrap();
row.insert(at.x, c);
}
@@ -64,6 +67,7 @@ impl Document {
if at.y >= len {
return;
}
+ self.dirty = true;
if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y < len - 1 {
let next_row = self.rows.remove(at.y + 1);
let row = self.rows.get_mut(at.y).unwrap();
@@ -74,14 +78,19 @@ impl Document {
}
}

- pub fn save(&self) -> Result<(), Error> {
+ pub fn save(&mut self) -> Result<(), Error> {
if let Some(file_name) = &self.file_name {
let mut file = fs::File::create(file_name)?;
for row in &self.rows {
file.write_all(row.as_bytes())?;
file.write_all(b"\n")?;
}
+ self.dirty = false;
}
Ok(())
}
+
+ pub fn is_dirty(&self) -> bool {
+ self.dirty
+ }
}
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
@@ -264,12 +264,22 @@ impl Editor {
fn draw_status_bar(&self) {
let mut status;
let width = self.terminal.size().width as usize;
+ let modified_indicator = if self.document.is_dirty() {
+ "(modified)"
+ } else {
+ ""
+ };
let mut file_name = "[No Name]".to_string();
if let Some(name) = &self.document.file_name {
file_name = name.clone();
file_name.truncate(20);
}
- status = format!("{} - {} lines", file_name, self.document.len());
+ status = format!(
+ "{} - {} lines {}",
+ file_name,
+ self.document.len(),
+ modified_indicator
+ );
let line_indicator = format!(
"{}/{}",
self.cursor_position.y.saturating_add(1),

我们还重新设置了insert中的边界处理。它现在和delete中的检查类似。

确认退出

如果用户尝试退出时,向他们警告未保存的更改。如果document.is_dirty()true,我们将在状态栏中显示一个警告,并要求用户再按三次Ctrl-Q才能不保存而退出。

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
@@ -10,6 +11,7 @@ use termion::event::Key;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const STATUS_BG_COLOR: color::LightWhite = color::LightWhite;
const STATUS_FG_COLOR: color::Black = color::Black;
+const QUIT_TIMES: u8 = 3;

#[derive(Default)]
pub struct Position {
@@ -37,6 +39,7 @@ pub struct Editor {
document: Document,
offset: Position,
status_message: StatusMessage,
+ quit_times: u8,
}

impl Editor {
@@ -77,6 +80,7 @@ impl Editor {
document,
offset: Position::default(),
status_message: StatusMessage::form(initial_status),
+ quit_times: QUIT_TIMES,
}
}

@@ -120,7 +124,17 @@ impl Editor {
fn process_keypress(&mut self) -> Result<(), std::io::Error> {
let pressed_key = Terminal::read_key()?;
match pressed_key {
- Key::Ctrl('q') => self.should_quit = true,
+ Key::Ctrl('q') => {
+ if self.quit_times > 0 && self.document.is_dirty() {
+ self.status_message = StatusMessage::form(format!(
+ "WARNING! File has unsaved changes. Press Ctrl-Q {} more times to quit.",
+ self.quit_times
+ ));
+ self.quit_times -= 1;
+ return Ok(());
+ }
+ self.should_quit = true;
+ }
Key::Ctrl('s') => self.save(),
Key::Char(c) => {
self.document.insert(&self.cursor_position, c);
@@ -144,6 +158,10 @@ impl Editor {
_ => (),
}
self.scroll();
+ if self.quit_times < QUIT_TIMES {
+ self.quit_times = QUIT_TIMES;
+ self.status_message = StatusMessage::form(String::new());
+ }
Ok(())
}

我们增加了一个新的常量,用于计算我们需要用户按下Ctrl-Q的附加次数,并将其作为Editor中的一个额外字段。当文档有未保存的更改并且用户尝试退出时,我们将quit_times计数减少,直到其达到0,然后最终退出。请注意,我们是在match对退出的分支内返回。这样,只有在用户按下的不是Ctrl-Q的其他键后,match之后的代码才会被调用,因此我们可以在match之后检查quit_times是否被修改,并在必要时重置它。

代码审查

现在,文本编辑器iTEditor的基本功能就已经全部实现了。现在我们可以回过头来检查一下我们的代码是否没有问题了,是不是存在一些潜在的问题。比如之前,我们为了防止溢出使用了saturated_adds

我们给Clippy引入一些新的技巧。

src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@@ -1,4 +1,13 @@
-#![allow(non_snake_case)]
+#![warn(clippy::all, clippy::pedantic, clippy::restriction)]
+#![allow(
+ non_snake_case,
+ clippy::missing_docs_in_private_items,
+ clippy::implicit_return,
+ clippy::shadow_reuse,
+ clippy::print_stdout,
+ clippy::wildcard_enum_match_arm,
+ clippy::else_if_without_else
+)]

mod document;
mod editor;

clippy::restriction包含了许多可能会或不会导致代码中错误的警告。现在运行cargo clippy时,可以看到非常多的问题!幸运的是,每个条目都附带了一个链接,链接中包含了一些解释。我们还为iTEditor禁用了一些条目,这样Clippy的结果更加易于管理。

现在我们按照Clippy的指导开始修改。

src/document.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
@@ -34,11 +34,15 @@ impl Document {
}

fn insert_new_line(&mut self, at: &Position) {
+ if at.y > self.len() {
+ return;
+ }
if at.y == self.len() {
self.rows.push(Row::default());
return;
} else {
let new_row = self.rows.get_mut(at.y).unwrap().split(at.x);
+ #[allow(clippy::integer_arithmetic)]
self.rows.insert(at.y + 1, new_row);
}
}
@@ -62,13 +66,14 @@ impl Document {
}
}

+ #[allow(clippy::integer_arithmetic)]
pub fn delete(&mut self, at: &Position) {
let len = self.len();
if at.y >= len {
return;
}
self.dirty = true;
- if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y < len - 1 {
+ if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y + 1 < len {
let next_row = self.rows.remove(at.y + 1);
let row = self.rows.get_mut(at.y).unwrap();
row.append(&next_row);
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
60
61
62
63
64
@@ -204,14 +204,14 @@ impl Editor {
Key::End => x = width,
Key::PageUp => {
y = if y > terminal_height {
- y - terminal_height
+ y.saturating_div(terminal_height)
} else {
0
}
}
Key::PageDown => {
y = if y.saturating_add(terminal_height) < height {
- y + terminal_height as usize
+ y.saturating_add(terminal_height)
} else {
height
}
@@ -233,7 +233,10 @@ impl Editor {
let height = self.terminal.size().height;
for terminal_row in 0..height {
Terminal::clear_current_line();
- if let Some(row) = self.document.row(terminal_row as usize + self.offset.y) {
+ if let Some(row) = self
+ .document
+ .row(self.offset.y.saturating_add(terminal_row as usize))
+ {
self.draw_row(row);
} else if self.document.is_empty() && terminal_row == height / 3 {
self.draw_welcome_message();
@@ -257,7 +260,7 @@ impl Editor {
pub fn draw_row(&self, row: &Row) {
let start = self.offset.x;
let width = self.terminal.size().width as usize;
- let end = self.offset.x + width;
+ let end = self.offset.x.saturating_add(width);
let row = row.render(start, end);
println!("{}\r", row)
}
@@ -302,10 +305,9 @@ impl Editor {
self.cursor_position.y.saturating_add(1),
self.document.len()
);
+ #[allow(clippy::integer_arithmetic)]
let len = status.len() + line_indicator.len();
- if width > len {
- status.push_str(&" ".repeat(width - len));
- }
+ status.push_str(&" ".repeat(width.saturating_sub(len)));
status = format!("{}{}", status, line_indicator);
status.truncate(width);
Terminal::set_bg_color(STATUS_BG_COLOR);
@@ -330,11 +332,7 @@ impl Editor {
self.status_message = StatusMessage::form(format!("{}{}", prompt, result));
self.refresh_screen()?;
match Terminal::read_key()? {
- Key::Backspace => {
- if !result.is_empty() {
- result.truncate(result.len() - 1);
- }
- }
+ Key::Backspace => result.truncate(result.len().saturating_sub(1)),
Key::Char('\n') => break,
Key::Char(c) => {
if !c.is_control() {
src/row.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@@ -23,6 +23,7 @@ impl Row {
let end = cmp::min(end, self.string.len());
let start = cmp::min(start, end);
let mut result = String::new();
+ #[allow(clippy::integer_arithmetic)]
for grapheme in self.string[..]
.graphemes(true)
.skip(start)
@@ -59,6 +60,7 @@ impl Row {
self.update_len();
}

+ #[allow(clippy::integer_arithmetic)]
pub fn delete(&mut self, at: usize) {
if at >= self.len() {
return;

主要修改了一下几个部分:

  • 在我们上一次修改中,insert_newline没有进行边界检查。仅从insert_newline来看,因为现在insert_newline仅被insert调用,在那里我们已经进行了边界检查。这意味着存在一个对调用insert_newline的隐含规则,确保at.y不会超过当前文档的长度。我们现在已经纠正了这一点。
  • 我们用加法替换了len的减法,变成了对at.y的加法。因为在那个函数中我们可以很容易看到y总是小于len,所以总有空间进行+1。不是很明显的是,len总是会大于0
  • 在使用saturating_sub时,我们能够去除一些大小比较,可以简化一些代码。

Clippy还给了我们一些关于整数除法的警告。问题是这样的:例如,如果你做除法,100/3,结果将是33,余数会被移除。在我们的程序中,这是可以的,但是解决这个问题的原因是相同的:任何审核我们代码的人都不能确定我们是否考虑到了这一点,或者简单地忘记了。我们能做的最少的事情是留下一个注释或一个clippy指令。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@@ -229,6 +229,7 @@ impl Editor {
self.cursor_position = Position { x, y }
}

+ #[allow(clippy::integer_division, clippy::integer_arithmetic)]
fn draw_rows(&self) {
let height = self.terminal.size().height;
for terminal_row in 0..height {
@@ -250,6 +251,7 @@ impl Editor {
let mut welcome_message = format!("iTEditor -- version {}\r", VERSION);
let width = self.terminal.size().width as usize;
let len = welcome_message.len();
+ #[allow(clippy::integer_arithmetic, clippy::integer_division)]
let padding = width.saturating_sub(len) / 2;
let spaces = " ".repeat(padding.saturating_sub(1));
welcome_message = format!("~{}{}", spaces, welcome_message);

现在,让我们来解决 clippy 的下一个问题。

src/document.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
60
61
62
63
64
65
@@ -34,21 +34,22 @@ impl Document {
}

fn insert_new_line(&mut self, at: &Position) {
- if at.y > self.len() {
+ if at.y > self.rows.len() {
return;
}
- if at.y == self.len() {
+ if at.y == self.rows.len() {
self.rows.push(Row::default());
return;
} else {
- let new_row = self.rows.get_mut(at.y).unwrap().split(at.x);
+ #[allow(clippy::indexing_slicing)]
+ let new_row = self.rows[at.y].split(at.x);
#[allow(clippy::integer_arithmetic)]
self.rows.insert(at.y + 1, new_row);
}
}

pub fn insert(&mut self, at: &Position, c: char) {
- if at.y > self.len() {
+ if at.y > self.rows.len() {
return;
}
self.dirty = true;
@@ -56,29 +57,30 @@ impl Document {
self.insert_new_line(at);
return;
}
- if at.y == self.len() {
+ if at.y == self.rows.len() {
let mut row = Row::default();
row.insert(0, c);
self.rows.push(row);
} else {
- let row = self.rows.get_mut(at.y).unwrap();
+ #[allow(clippy::indexing_slicing)]
+ let row = &mut self.rows[at.y];
row.insert(at.x, c);
}
}

- #[allow(clippy::integer_arithmetic)]
+ #[allow(clippy::integer_arithmetic, clippy::indexing_slicing)]
pub fn delete(&mut self, at: &Position) {
- let len = self.len();
+ let len = self.rows.len();
if at.y >= len {
return;
}
self.dirty = true;
- if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y + 1 < len {
+ if at.x == self.rows[at.y].len() && at.y + 1 < len {
let next_row = self.rows.remove(at.y + 1);
- let row = self.rows.get_mut(at.y).unwrap();
+ let row = &mut self.rows[at.y];
row.append(&next_row);
} else {
- let row = self.rows.get_mut(at.y).unwrap();
+ let row = &mut self.rows[at.y];
row.delete(at.x);
}
}

这个变更主要与在某些位置访问行有关。我们之前使用的是安全的get_mut方法,即便没有内容可检索也不会引发panic,比如因为索引错误。我们直接在其上调用了unwrap(),这实际上否定了首先使用get_mut的好处。我们现在已经替换了这种做法,改为直接访问self.rows。我们在每个这样做的地方留下了一个clippy声明,以表明我们确实在那个时刻检查了我们仅仅在访问有效的索引。

之前还有一个隐含的约定,那就是文档的长度始终对应于其中的行数,因此我们到处都在调用self.len()而不是self.rows.len()。如果我们的文档更长,所有在self.row上的操作都会失效。

好了,现在还有两个clippy警告要解决。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@@ -59,11 +59,10 @@ impl Editor {
pub fn default() -> Self {
let args: Vec<String> = env::args().collect();
let mut initial_status = String::from("HELP: Ctrl-S = save | Ctrl-Q = quit");
- let document = if args.len() > 1 {
- let file_name = &args[1];
- let doc = Document::open(&file_name);
- if doc.is_ok() {
- doc.unwrap()
+ let document = if let Some(file_name) = args.get(1) {
+ let doc = Document::open(file_name);
+ if let Ok(doc) = doc {
+ doc
} else {
initial_status = format!("ERRROR: Could not open file {}", file_name);
Document::default()

get函数有一个特点,它仅在值存在时才返回值给我们,这消除了索引前进行检查的需要。然后,我们去除了is_ok以支持if let结构,以此来省去unwrap

现在,还有一些问题是Clippy检测不到的,我们不应该仅仅依赖Clippy进行开发。接下来我们将处理这些问题。

性能改进

目前我们的编辑器并没有做太多事情。但是,我们可以做一些性能改进。性能调整是一个复杂的话题,因为我们需要在可读、可维护的代码和超级优化、难以阅读和维护的代码之间找到平衡。我们并不期望iTEditor成为最快的编辑器,但关注一些性能方面的事情是有意义的。

我希望我们能注意到在遍历文档时对行进行不必要的迭代,以及在从左到右遍历行时对字符进行不必要的迭代。现在我们就专注于这些问题,不做额外的缓存,也不用花哨的技巧,只是寻找重复的操作。

让我们关注一下我们如何处理行。我们有一个我们多次重复的通用模式。例如,这里的insert:

1
2
3
4
5
6
7
8
9
10
11
12
pub fn insert(&mut self, at: usize, c: char) {
if at >= self.len() {
self.string.push(c);
} else {
let mut resule: String = self.string[..].graphemes(true).take(at).collect();
let remainder: String = self.string[..].graphemes(true).skip(at).collect();
resule.push(c);
resule.push_str(&remainder);
self.string = resule;
}
self.update_len();
}

在这个实现中,我们对字符串进行了三次迭代:

  • 从开始到at位置计算result
  • 第二次从开始到结束(忽略开始和at之间的所有内容)来计算remainder
  • 然后整个字符串迭代一次来更新len

这并不理想。让我们来优化一下。

src/row.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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
@@ -9,12 +9,10 @@ pub struct Row {

impl From<&str> for Row {
fn from(slice: &str) -> Self {
- let mut row = Self {
+ Self {
string: String::from(slice),
- len: 0,
- };
- row.update_len();
- row
+ len: slice.graphemes(true).count(),
+ }
}
}

@@ -43,47 +41,69 @@ impl Row {
pub fn is_empty(&self) -> bool {
self.len == 0
}
- pub fn update_len(&mut self) {
- self.len = self.string[..].graphemes(true).count();
- }

pub fn insert(&mut self, at: usize, c: char) {
if at >= self.len() {
self.string.push(c);
- } else {
- let mut resule: String = self.string[..].graphemes(true).take(at).collect();
- let remainder: String = self.string[..].graphemes(true).skip(at).collect();
- resule.push(c);
- resule.push_str(&remainder);
- self.string = resule;
+ self.len += 1;
+ return;
}
- self.update_len();
+ let mut result: String = String::new();
+ let mut length = 0;
+ for (index, grapheme) in self.string[..].graphemes(true).enumerate() {
+ length += 1;
+ if index == at {
+ length += 1;
+ result.push(c);
+ }
+ result.push_str(grapheme);
+ }
+ self.len = length;
+ self.string = result;
}

- #[allow(clippy::integer_arithmetic)]
pub fn delete(&mut self, at: usize) {
if at >= self.len() {
return;
- } else {
- let mut result: String = self.string[..].graphemes(true).take(at).collect();
- let remainder: String = self.string[..].graphemes(true).skip(at + 1).collect();
- result.push_str(&remainder);
- self.string = result;
}
- self.update_len();
+ let mut result: String = String::new();
+ let mut length = 0;
+ for (index, grapheme) in self.string[..].graphemes(true).enumerate() {
+ if index != at {
+ length += 1;
+ result.push_str(grapheme);
+ }
+ }
+ self.len = length;
+ self.string = result;
}

pub fn append(&mut self, new_row: &Self) {
self.string = format!("{}{}", self.string, new_row.string);
- self.update_len();
+ self.len += new_row.len;
}

pub fn split(&mut self, at: usize) -> Self {
- let beginning: String = self.string[..].graphemes(true).take(at).collect();
- let remainder: String = self.string[..].graphemes(true).skip(at).collect();
- self.string = beginning;
- self.update_len();
- Self::from(&remainder[..])
+ let mut row: String = String::new();
+ let mut length = 0;
+ let mut splitted_row: String = String::new();
+ let mut splitted_length = 0;
+ for (index, grapheme) in self.string[..].graphemes(true).enumerate() {
+ if index < at {
+ length += 1;
+ row.push_str(grapheme);
+ } else {
+ splitted_length += 1;
+ splitted_row.push_str(grapheme);
+ }
+ }
+ self.string = row;
+ self.len = length;
+
+ Self {
+ string: splitted_row,
+ len: splitted_length,
+ }
}

pub fn as_bytes(&self) -> &[u8] {

我们在这里做了两件事:

  • 我们放弃了update_len,因为我们现在在每次行操作时都会手动计算长度。
  • 我们正在对enumerate进行迭代,这不仅提供了下一个元素,还提供了迭代器中的索引。这样,我们就可以在遍历行时轻松计算长度。

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


Rust学习-制作一个文本编辑器: A Text Editor
https://zone.ivanz.cc/p/a052054
作者
Ivan Zhang
发布于
2023年11月30日
更新于
2024年1月10日
许可协议