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

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

显示行

我们需要一个额外的数据结构:Document,代表用户当前正在编辑的文档,以及文档中的Row

src/document.rs
1
2
3
4
5
6
7
@@ -0,0 +1,6 @@
+use crate::Row;
+
+#[derive(Default)]
+pub struct Document {
+ rows: Vec<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
@@ -1,5 +1,6 @@
use crate::Terminal;
use termion::event::Key;
+use crate::Document;

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

@@ -11,6 +12,7 @@ pub struct Editor {
should_quit: bool,
terminal: Terminal,
cursor_position: Position,
+ document: Document,
}

impl Editor {
@@ -33,6 +35,7 @@ impl Editor {
should_quit: false,
terminal: Terminal::default().expect("Failed to initialize terminal"),
cursor_position: Position { x: 0, y: 0 },
+ document: Document::default(),
}
}
src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@@ -2,10 +2,14 @@

mod editor;
mod terminal;
+mod row;
+mod document;

use editor::Editor;
pub use terminal::Terminal;
pub use editor::Position;
+pub use row::Row;
+pub use document::Document;

fn main() {
Editor::default().run()
src/row.rs
1
2
3
+pub struct Row {
+ string: String
+}

在这里,我们为代码引入了两个新概念。首先,我们使用了一个叫做Vector的数据结构来存放我们的行。Vector是一种动态结构:它可以在运行时增长或缩小,因为我们在添加或删除元素。Vec<Row>这一语法意味着这个向量将会存放Row类型的元素。

另一个是这行代码:#[derive(Default)]。它表示Rust编译器应该为default派生一个实现。default应该返回一个其内容初始化为默认值的结构体——这是编译器可以为我们做的。有了这个指令,我们就不需要自己实现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
@@ -4,6 +4,7 @@ use crate::Document;

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

+#[derive(Default)]
pub struct Position {
pub x: usize,
pub y: usize,
@@ -34,7 +35,7 @@ impl Editor {
Self {
should_quit: false,
terminal: Terminal::default().expect("Failed to initialize terminal"),
- cursor_position: Position { x: 0, y: 0 },
+ cursor_position: Position::default(),
document: Document::default(),
}
}
@@ -42,7 +43,7 @@ impl Editor {
fn refresh_screen(&self) -> Result<(), std::io::Error> {
Terminal::cursor_hide();
Terminal::clear_screen();
- Terminal::cursor_position(&Position { x: 0, y: 0 });
+ Terminal::cursor_position(&Position::default());
if self.should_quit {
Terminal::clear_screen();
println!("Goodbye.\r");

通过为Position派生default,我们消除了将光标位置初始化为(0, 0)的重复代码。如果将来我们决定以不同的方式初始化Position,我们可以自己实现default而不需要触及其他代码。

我们不能为其他更复杂的结构体派生default——Rust无法猜测所有结构体成员的默认值。

让我们在Document中填充一些文本。我们目前不用考虑从文件中读取内容,现在,我们将直接编写一个"Hello, World"字符串到其中。

src/document.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@@ -3,4 +3,12 @@ use crate::Row;
#[derive(Default)]
pub struct Document {
rows: Vec<Row>,
-}
\ No newline at end of file
+}
+
+impl Document {
+ pub fn open() -> Self {
+ let mut rows = Vec::new();
+ rows.push(Row::from("Hello, World!"));
+ Self { rows }
+ }
+}
src/editor.rs
1
2
3
4
5
6
7
8
@@ -36,7 +36,7 @@ impl Editor {
should_quit: false,
terminal: Terminal::default().expect("Failed to initialize terminal"),
cursor_position: Position::default(),
- document: Document::default(),
+ document: Document::open(),
}
}
src/row.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
@@ -1,3 +1,11 @@
pub struct Row {
- string: String
+ string: String,
+}
+
+impl From<&str> for Row {
+ fn from(slice: &str) -> Self {
+ Self {
+ string: String::from(slice),
+ }
+ }
}

from函数中,我们使用了From特性,它允许类型定义如何从另一种类型创建自身,从而提供了一种非常简单的机制来在多种类型之间进行转换。

之后我们会实现一个从文件打开Document的方法,我们会再次使用1来初始化我们的editor。但现在,让我们先关注如何显示。

src/document.rs
1
2
3
4
5
6
7
8
@@ -11,4 +11,7 @@ impl Document {
rows.push(Row::from("Hello, World!"));
Self { rows }
}
+ pub fn row(&self, index: usize) -> Option<&Row> {
+ self.rows.get(index)
+ }
}
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
@@ -1,6 +1,7 @@
+use crate::Document;
+use crate::Row;
use crate::Terminal;
use termion::event::Key;
-use crate::Document;

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

@@ -101,9 +102,11 @@ impl Editor {

fn draw_rows(&self) {
let height = self.terminal.size().height;
- for row in 0..height - 1 {
+ for terminal_row in 0..height - 1 {
Terminal::clear_current_line();
- if row == height / 3 {
+ if let Some(row) = self.document.row(terminal_row as usize) {
+ self.draw_row(row);
+ } else if terminal_row == height / 3 {
self.draw_welcome_message();
} else {
println!("~\r")
@@ -121,6 +124,13 @@ impl Editor {
welcome_message.truncate(width);
println!("{}\r", welcome_message);
}
+
+ pub fn draw_row(&self, row: &Row) {
+ let start = 0;
+ let end = self.terminal.size().width as usize;
+ let row = row.render(start, end);
+ println!("{}\r", row)
+ }
}
src/row.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@@ -1,3 +1,5 @@
+use std::cmp;
+
pub struct Row {
string: String,
}
@@ -9,3 +11,11 @@ impl From<&str> for Row {
}
}
}
+
+impl Row {
+ pub fn render (&self, start: usize, end: usize) ->String {
+ let end = cmp::min(end,self.string.len());
+ let start = cmp::min(start,end);
+ self.string.get(start..end).unwrap_or_default().to_string()
+ }
+}

我们为Row其添加了一个名为render的方法,它会返回它能生成的最大可能的子字符串。我们也使用了unwrap_or_default,尽管这里不是必需的,因为我们已经事先清理了起始和结束参数。最后一行的操作是我们尝试从字符串中创建一个子字符串,并将其转换成字符串,或者转换成默认值("")。在Rust中,Stringstr是有区别的。

Document中,我们添加了一个方法来检索特定索引处的Row。我们使用Vectorget方法:如果索引超出范围则返回None,否则返回拥有的Row

现在让我们看Editor。在draw_rows中,我们首先将变量row重命名为terminal_row以避免与我们现在从Document中获取的行混淆。然后我们检索row并显示它。Row确保返回一个可以显示的子字符串,而Editor确保满足终端尺寸的要求。

然而,我们的欢迎消息仍然被显示出来。当用户打开一个文件时,我们不希望显示这个消息,所以让我们为Document添加一个is_empty方法,并在draw_rows中检查它。

src/document.rs
1
2
3
4
5
6
7
8
@@ -14,4 +14,7 @@ impl Document {
pub fn row(&self, index: usize) -> Option<&Row> {
self.rows.get(index)
}
+ pub fn is_empty(&self) -> bool {
+ self.rows.is_empty()
+ }
}
src/editor.rs
1
2
3
4
5
6
7
8
9
@@ -106,7 +106,7 @@ impl Editor {
Terminal::clear_current_line();
if let Some(row) = self.document.row(terminal_row as usize) {
self.draw_row(row);
- } else if terminal_row == height / 3 {
+ } else if self.document.is_empty() && terminal_row == height / 3 {
self.draw_welcome_message();
} else {
println!("~\r")

现在屏幕中间不再显示那条消息了。接下来,让我们允许用户打开并显示实际文件。我们从修改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
23
@@ -1,4 +1,5 @@
use crate::Row;
+use std::fs;

#[derive(Default)]
pub struct Document {
@@ -6,10 +7,13 @@ pub struct Document {
}

impl Document {
- pub fn open() -> Self {
+ pub fn open(filename: &str) -> Result<Self, std::io::Error> {
+ let contents = fs::read_to_string(filename)?;
let mut rows = Vec::new();
- rows.push(Row::from("Hello, World!"));
- Self { rows }
+ for value in contents.lines() {
+ rows.push(Row::from(value));
+ }
+ Ok(Self { rows })
}
pub fn row(&self, index: usize) -> Option<&Row> {
self.rows.get(index)
src/editor.rs
1
2
3
4
5
6
7
8
@@ -37,7 +37,7 @@ impl Editor {
should_quit: false,
terminal: Terminal::default().expect("Failed to initialize terminal"),
cursor_position: Position::default(),
- document: Document::open(),
+ document: Document::default(),
}
}

我们目前在启动时使用一个默认的Document,并更新了open方法,该方法尝试打开一个文件,并在失败时返回错误。open将行读入我们的Document结构体。需要注意的是,rows中的每一行都不会包含行结束符\n或者\r\n,因为Rustline()方法会剪切掉它们。

现在让我们实际使用open来打开一个文件,这个文件将通过命令行传递给iTEditor

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
@@ -1,6 +1,7 @@
use crate::Document;
use crate::Row;
use crate::Terminal;
+use std::env;
use termion::event::Key;

const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -33,11 +34,19 @@ impl Editor {
}

pub fn default() -> Self {
+ let args: Vec<String> = env::args().collect();
+ let document = if args.len() > 1 {
+ let file_name = &args[1];
+ Document::open(&file_name).unwrap_or_default()
+ } else {
+ Document::default()
+ };
+
Self {
should_quit: false,
terminal: Terminal::default().expect("Failed to initialize terminal"),
cursor_position: Position::default(),
- document: Document::default(),
+ document,
}
}

滚动浏览

现在,我们希望能够滚动浏览整个文件,而不仅仅是看到文件的前几行。让我们给编辑器状态添加一个偏移量offset,用以追踪用户当前滚动到文件的哪一行。我们将复用Position结构体。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@@ -16,6 +16,7 @@ pub struct Editor {
terminal: Terminal,
cursor_position: Position,
document: Document,
+ offset: Position,
}

impl Editor {
@@ -47,6 +48,7 @@ impl Editor {
terminal: Terminal::default().expect("Failed to initialize terminal"),
cursor_position: Position::default(),
document,
+ offset: Position::default(),
}
}

defult来初始化它,这意味着默认情况下文件会滚动到左上角。

现在,让我们修改draw_row()函数,使其能够根据offset.x的值显示文件正确的行范围,同时让draw_rows()函数根据offset.y的值显示正确的行数范围。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@@ -115,7 +115,7 @@ impl Editor {
let height = self.terminal.size().height;
for terminal_row in 0..height - 1 {
Terminal::clear_current_line();
- if let Some(row) = self.document.row(terminal_row as usize) {
+ if let Some(row) = self.document.row(terminal_row as usize + self.offset.y) {
self.draw_row(row);
} else if self.document.is_empty() && terminal_row == height / 3 {
self.draw_welcome_message();
@@ -137,8 +137,9 @@ impl Editor {
}

pub fn draw_row(&self, row: &Row) {
- let start = 0;
- let end = self.terminal.size().width as usize;
+ let start = self.offset.x;
+ let width = self.terminal.size().width as usize;
+ let end = self.offset.x + width;
let row = row.render(start, end);
println!("{}\r", row)
}

我们将偏移量添加到字符串的开始和结束位置,以获得我们所需的字符串切片。我们也确保了在字符串长度不足以填充屏幕时的情况能得到处理。如果当前行在屏幕左侧结束(如果我们在一行很长的行中滚动到右侧时可能会发生),Rowrender方法将返回一个空字符串。

我们在哪里设置offset的值呢?我们的策略将是检查光标是否已移动到可见窗口之外,如果是的话,就调整offset以使光标正好在可见窗口内。我们将这个逻辑放在一个名为scroll的函数中,并在处理按键后立即调用它。

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
@@ -81,6 +81,7 @@ impl Editor {
| Key::PageDown => self.move_cursor(pressed_key),
_ => (),
}
+ self.scroll();
Ok(())
}

@@ -143,6 +144,23 @@ impl Editor {
let row = row.render(start, end);
println!("{}\r", row)
}
+
+ pub fn scroll(&mut self) {
+ let Position { x, y } = self.cursor_position;
+ let width = self.terminal.size().width as usize;
+ let height = self.terminal.size().height as usize;
+ let offset = &mut self.offset;
+ if y < offset.y {
+ offset.y = y;
+ } else if y >= offset.y.saturating_add(height) {
+ offset.y = y.saturating_sub(height).saturating_add(1);
+ }
+ if x < offset.x {
+ offset.x = x;
+ } else if x >= offset.x.saturating_add(width) {
+ offset.x = x.saturating_sub(width).saturating_add(1);
+ }
+ }
}

要使用scroll函数,我们需要知道终端的宽度和高度以及当前位置,并且我们想要在self.offset中更改值。如果我们已经向左或向上移动,我们想要将我们的偏移设置为文档中的新位置。如果我们向右滚动得太远,我们将从新位置减去当前偏移来计算新偏移。

现在,让我们允许光标超过屏幕底部前进(但不超过文件底部)。

src/document.rs
1
2
3
4
5
6
7
8
@@ -21,4 +21,7 @@ impl Document {
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
+ pub fn len(&self) -> usize {
+ self.rows.len()
+ }
}
src/editor.rs
1
2
3
4
5
6
7
8
9
@@ -88,7 +88,7 @@ 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 height = self.document.len();
let width = size.width.saturating_sub(1) as usize;
match key {
Key::Up => y = y.saturating_sub(1),

现在,当你运行cargo run Cargo.lock时,就能够滚动浏览整个文件。最后一行的处理可能会有点奇怪,因为我们把光标放在那里,但没有在那里渲染。当我们在本章稍后加入状态栏时,这个问题将得到修复。如果你尝试向上滚动,你可能会注意到光标没有被正确定位。**这是因为状态中的Position不再指屏幕上光标的位置。它指的是光标在文本文件中的位置,但我们仍然将其传递给cursor_position。**为了在屏幕上定位光标,我们现在需要从文档中的位置减去偏移量。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
@@ -61,7 +61,10 @@ impl Editor {
println!("Goodbye.\r");
} else {
self.draw_rows();
- Terminal::cursor_position(&self.cursor_position);
+ Terminal::cursor_position(&Position {
+ x: self.cursor_position.x.saturating_sub(self.offset.x),
+ y: self.cursor_position.y.saturating_sub(self.offset.y),
+ })
}
Terminal::cursor_show();
Terminal::flush()

现在,让我们来解决水平滚动的问题。我们还没有允许光标滚动超过屏幕的右边界。我们将采取与向下滚动相同的对称处理方法来解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@@ -90,9 +90,12 @@ 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 = self.document.len();
- let width = size.width.saturating_sub(1) as usize;
+ let width = if let Some(row) = self.document.row(y){
+ row.len()
+ } else {
+ 0
+ };
match key {
Key::Up => y = y.saturating_sub(1),
Key::Down => {
src/row.rs
1
2
3
4
5
6
7
8
9
10
11
@@ -18,4 +18,10 @@ impl Row {
let start = cmp::min(start, end);
self.string.get(start..end).unwrap_or_default().to_string()
}
+ pub fn len(&self) -> usize {
+ self.string.len()
+ }
+ pub fn is_empty(&self) -> bool {
+ self.string.is_empty()
+ }
}

我们所需要做的就是改变move_cursor使用的宽度。这样,水平滚动现在可以工作了。除此之外,当有了一个len函数时,实现is_empty函数是一个好习惯。

处理光标在行末的情况

现在cursor_position指的是光标在文件内的位置,而不是它在屏幕上的位置。我们接下来几步的目标是限制cursor_position的值,只指向文件中的有效位置,除了一种情况外,即允许光标指向行尾之后的一个字符位置,或者文件末尾之后,这样用户就可以在行末或文件末轻松添加新字符。

我们已经能够防止用户将光标滚动得太远至右侧或下方。然而,用户仍然可以将光标移动到一行的末尾之后。他们可以通过将光标移到一长行的末尾,然后将其下移至更短的下一行来实现。这样cursor_position.y的值不会变,光标就会出现在它所在行的末尾的右侧。

现在我们修改一下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
@@ -91,7 +91,7 @@ impl Editor {
fn move_cursor(&mut self, key: Key) {
let Position { mut x, mut y } = self.cursor_position;
let height = self.document.len();
- let width = if let Some(row) = self.document.row(y){
+ let mut width = if let Some(row) = self.document.row(y) {
row.len()
} else {
0
@@ -115,6 +115,14 @@ impl Editor {
Key::PageDown => y = height,
_ => (),
}
+ width = if let Some(row) = self.document.row(y) {
+ row.len()
+ } else {
+ 0
+ };
+ if x > width {
+ x = width;
+ }
self.cursor_position = Position { x, y }
}

我们需要再次设置width,因为在处理键盘输入时,row可能已经改变。然后,我们设置x的新值,确保x的值不超过当前行的宽度。

Page UpPage Down翻页

现在我们已经实现了滚动功能,让我们设置Page UpPage Down键使其能够滚动整个页面而不是整个文档。

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
@@ -89,6 +89,7 @@ impl Editor {
}

fn move_cursor(&mut self, key: Key) {
+ let terminal_height = self.terminal.size().height as usize;
let Position { mut x, mut y } = self.cursor_position;
let height = self.document.len();
let mut width = if let Some(row) = self.document.row(y) {
@@ -111,8 +112,20 @@ impl Editor {
}
Key::Home => x = 0,
Key::End => x = width,
- Key::PageUp => y = 0,
- Key::PageDown => y = height,
+ Key::PageUp => {
+ y = if y > terminal_height {
+ y - terminal_height
+ } else {
+ 0
+ }
+ },
+ Key::PageDown => {
+ y = if y.saturating_add(terminal_height) < height {
+ y + terminal_height as usize
+ } else {
+ height
+ }
+ },
_ => (),
}
width = if let Some(row) = self.document.row(y) {

行首返回上一行

在光标在行首时,按⬅️将跳转到上一行末尾。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@@ -104,7 +104,18 @@ impl Editor {
y = y.saturating_add(1);
}
}
- Key::Left => x = x.saturating_sub(1),
+ Key::Left => {
+ if x > 0 {
+ x -= 1;
+ } else if y > 0 {
+ y -= 1;
+ if let Some(row) = self.document.row(y) {
+ x = row.len();
+ } else {
+ x = 0;
+ }
+ }
+ }
Key::Right => {
if x < width {
x = x.saturating_add(1);

行末跳转下一行

在光标在行末尾时,按➡️将跳转到下一行开始。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
@@ -118,7 +118,10 @@ impl Editor {
}
Key::Right => {
if x < width {
- x = x.saturating_add(1);
+ x += 1;
+ } else if y < height {
+ y += 1;
+ x = 0;
}
}
Key::Home => x = 0,

特殊字符的长度

现在,如果我们在文本文件中输入一些emoji或者其他特殊符号的话

1
2
3
4
aaa
äää
yyy
❤❤❤

当你在该文件中滚动时,你会注意到只有第一行能够正确地向右滚动,其他所有行都会让你滚动到行尾之外。

这是由于某些符号会占用多个字节。比如,德语元音变音符号,如ä会返回多个字节。我们可以通过Rust playground来查看一下不同字符所占用的大小。

1
2
3
4
[src/main.rs:2] "aaa".to_string().len() = 3
[src/main.rs:3] "äää".to_string().len() = 6
[src/main.rs:4] "y̆y̆y̆".to_string().len() = 9
[src/main.rs:5] "❤❤❤".to_string().len() = 9

我们现在可以看到,字符串的长度可能比我们原本认为的要大,因为某些字符实际上占用的不只是一个字节,或者它们由多个字符组成。

那么,对我们来说一行的长度是多少呢?从根本上说,它是屏幕上我们的鼠标可以移动过的一个元素。这被称为一个Grapheme,而Rust与其他一些语言不同,并不默认支持Grapheme。这意味着我们要么需要自己编写代码来处理它们,要么使用一个已有的来完成这项工作。

Cargo.toml
1
2
3
4
5
@@ -7,3 +7,4 @@ edition = "2021"

[dependencies]
termion = "1"
+unicode-segmentation = "1"
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
@@ -1,4 +1,5 @@
use std::cmp;
+use unicode_segmentation::UnicodeSegmentation;

pub struct Row {
string: String,
@@ -16,10 +17,18 @@ impl Row {
pub fn render(&self, start: usize, end: usize) -> String {
let end = cmp::min(end, self.string.len());
let start = cmp::min(start, end);
- self.string.get(start..end).unwrap_or_default().to_string()
+ let mut result = String::new();
+ for grapheme in self.string[..]
+ .graphemes(true)
+ .skip(start)
+ .take(end - start)
+ {
+ result.push_str(grapheme);
+ }
+ result
}
pub fn len(&self) -> usize {
- self.string.len()
+ self.string[..].graphemes(true).count()
}
pub fn is_empty(&self) -> bool {
self.string.is_empty()

我们对完整字符串的切片[..]执行graphemes()方法,然后使用这个迭代器。在使用len()的情况下,我们调用迭代器上的count()方法,这告诉我们有多少个Grapheme。在render的情况下,我们现在开始构建我们自己的字符串,而不是使用内建的方法。为此,我们跳过屏幕左侧的第一个Grapheme,只取end-startGrapheme(即可见部分)。这些Grapheme随后被推入返回值中。

虽然这个方法有效,但性能并不是最优的。count实际上会遍历整个迭代器然后返回值。这意味着对于每个可见的行,我们都在重复计算整行的长度。现在让我们记录这个长度。

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
@@ -3,13 +3,17 @@ use unicode_segmentation::UnicodeSegmentation;

pub struct Row {
string: String,
+ len: usize,
}

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

@@ -28,9 +32,12 @@ impl Row {
result
}
pub fn len(&self) -> usize {
- self.string[..].graphemes(true).count()
+ self.len
}
pub fn is_empty(&self) -> bool {
- self.string.is_empty()
+ self.len == 0
+ }
+ pub fn update_len(&mut self) {
+ self.len = self.string[..].graphemes(true).count();
}
}

现在我们只需在每次行发生变化时调用update_len

渲染Tab

如果你尝试打开一个包含制表符\t的文件,你会注意到一个制表符字符大约占用8列宽。关于是否使用制表符或空格进行缩进,这是一个长期且持续的争论。在这里我选择用两个空格来代替制表符

src/row.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
@@ -27,7 +27,11 @@ impl Row {
.skip(start)
.take(end - start)
{
- result.push_str(grapheme);
+ if grapheme == "\t" {
+ result.push_str(" ")
+ } else {
+ result.push_str(grapheme);
+ }
}
result
}

状态栏

现在我们要为iTEditor增加一个状态栏来显示一些有用的信息,比如文件名、文件中的行数,以及当前所在的行。

首先,先在屏幕底部腾出两行状态栏的空间。

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

fn draw_rows(&self) {
let height = self.terminal.size().height;
- for terminal_row in 0..height - 1 {
+ for terminal_row in 0..height {
Terminal::clear_current_line();
if let Some(row) = self.document.row(terminal_row as usize + self.offset.y) {
self.draw_row(row);
src/terminal.rs
1
2
3
4
5
6
7
8
9
@@ -21,7 +21,7 @@ impl Terminal {
Ok(Self {
size: Size {
width: size.0,
- height: size.1,
+ height: size.1.saturating_sub(2),
},
_stdout: stdout().into_raw_mode()?,
})

为了让状态栏更加突出,我们将对其进行着色显示。我们将使用 termion 的功能来提供RGB颜色,如果当前终端不支持这些颜色,就会回退到更简单的颜色。

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
@@ -2,9 +2,11 @@ use crate::Document;
use crate::Row;
use crate::Terminal;
use std::env;
+use termion::color;
use termion::event::Key;

const VERSION: &str = env!("CARGO_PKG_VERSION");
+const STATUS_BG_COLOR: color::Rgb = color::Rgb(239, 239, 239);

#[derive(Default)]
pub struct Position {
@@ -61,6 +63,8 @@ impl Editor {
println!("Goodbye.\r");
} else {
self.draw_rows();
+ self.draw_status_bar();
+ self.draw_message_bar();
Terminal::cursor_position(&Position {
x: self.cursor_position.x.saturating_sub(self.offset.x),
y: self.cursor_position.y.saturating_sub(self.offset.y),
@@ -202,6 +206,15 @@ impl Editor {
offset.x = x.saturating_sub(width).saturating_add(1);
}
}
+ fn draw_status_bar(&self) {
+ let spaces = " ".repeat(self.terminal.size().width as usize);
+ Terminal::set_bg_color(STATUS_BG_COLOR);
+ println!("{}\r", spaces);
+ Terminal::reset_bg_color();
+ }
+ fn draw_message_bar(&self) {
+ Terminal::clear_current_line();
+ }
}

fn die(e: std::io::Error) {
src/terminal.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@@ -1,4 +1,5 @@
use std::io::{self, stdout, Write};
+use termion::color;
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::{IntoRawMode, RawTerminal};
@@ -67,4 +68,12 @@ impl Terminal {
pub fn clear_current_line() {
print!("{}", termion::clear::CurrentLine);
}
+
+ pub fn set_bg_color(color: color::Rgb) {
+ print!("{}", color::Bg(color));
+ }
+
+ pub fn reset_bg_color() {
+ print!("{}", color::Bg(color::Reset));
+ }
}

在某些终端(例如Mac)上,颜色将无法正确显示。可以使用termion::style::Invert

为了由于我是用的是MBP,上面的代码在终端中显示并不正常,因为MAC的终端只支持16ANIS颜色,可以进行替换,如:

src/editor.rs
1
2
3
4
5
6
7
8
@@ -8,8 +8,8 @@ use termion::color;
use termion::event::Key;

const VERSION: &str = env!("CARGO_PKG_VERSION");
-const STATUS_BG_COLOR: color::Rgb = color::Rgb(239, 239, 239);
-const STATUS_FG_COLOR: color::Rgb = color::Rgb(63, 63, 63);
+const STATUS_BG_COLOR: color::LightWhite = color::LightWhite;
+const STATUS_FG_COLOR: color::Black = color::Black;
src/terminal.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@@ -69,7 +69,7 @@ impl Terminal {
print!("{}", termion::clear::CurrentLine);
}

- pub fn set_bg_color(color: color::Rgb) {
+ pub fn set_bg_color(color: color::LightWhite) {
print!("{}", color::Bg(color));
}

@@ -77,7 +77,7 @@ impl Terminal {
print!("{}", color::Bg(color::Reset));
}

- pub fn set_fg_color(color: color::Rgb) {
+ pub fn set_fg_color(color: color::Black) {
print!("{}", color::Fg(color));
}

我们需要在使用颜色之后重置颜色,否则屏幕的其余部分也会被渲染成同样的颜色。

我们在terminal中使用这个功能来绘制状态栏将要显示的空白行。我们还添加了一个功能来绘制状态栏下方的message bar,但目前我们将其留空。

接下来,我们想要显示文件名。让我们调整我们的Document来有一个可选的文件名,并在open中设置它。我们也将准备终端来设置和重置前景色。

src/document.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@@ -4,6 +4,7 @@ use std::fs;
#[derive(Default)]
pub struct Document {
rows: Vec<Row>,
+ file_name: Option<String>,
}

impl Document {
@@ -13,7 +14,10 @@ impl Document {
for value in contents.lines() {
rows.push(Row::from(value));
}
- Ok(Self { rows })
+ Ok(Self {
+ rows,
+ file_name: Some(filename.to_string()),
+ })
}
pub fn row(&self, index: usize) -> Option<&Row> {
self.rows.get(index)
src/terminal.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
@@ -76,4 +76,12 @@ impl Terminal {
pub fn reset_bg_color() {
print!("{}", color::Bg(color::Reset));
}
+
+ pub fn set_fg_color(color: color::Rgb) {
+ print!("{}", color::Fg(color));
+ }
+
+ pub fn reset_fg_color() {
+ print!("{}", color::Fg(color::Reset));
+ }
}

需要注意的是,我们没有使用String作为文件名的类型,而是用了Option,以此表明我们可能有一个文件名,或者在没有设置文件名的情况下为None

现在我们准备在状态栏显示一些信息。我们将显示最多20个字符的文件名,然后是文件中的行数。如果没有文件名,我们将显示[No Name]

src/document.rs
1
2
3
4
5
6
7
@@ -4,7 +4,7 @@ use std::fs;
#[derive(Default)]
pub struct Document {
rows: Vec<Row>,
- file_name: Option<String>,
+ pub file_name: Option<String>,
}
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
@@ -7,6 +7,7 @@ use termion::event::Key;

const VERSION: &str = env!("CARGO_PKG_VERSION");
const STATUS_BG_COLOR: color::Rgb = color::Rgb(239, 239, 239);
+const STATUS_FG_COLOR: color::Rgb = color::Rgb(63, 63, 63);

#[derive(Default)]
pub struct Position {
@@ -207,10 +208,23 @@ impl Editor {
}
}
fn draw_status_bar(&self) {
- let spaces = " ".repeat(self.terminal.size().width as usize);
+ let mut status;
+ let width = self.terminal.size().width as usize;
+ 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());
+ if width > status.len() {
+ status.push_str(&" ".repeat(width - status.len()));
+ }
+ status.truncate(width);
Terminal::set_bg_color(STATUS_BG_COLOR);
- println!("{}\r", spaces);
+ Terminal::set_fg_color(STATUS_FG_COLOR);
+ println!("{}\r", status);
Terminal::reset_bg_color();
+ Terminal::reset_fg_color();
}
fn draw_message_bar(&self) {
Terminal::clear_current_line();

我们确保在窗口宽度内适当地截断状态栏字符串。请注意,我们仍然使用绘制空格到屏幕末端的代码,这样整个状态栏就会有一个白色背景。

我们这里使用了一个新的宏,format!。它可以将字符串重新格式化并不打印到屏幕上。

现在,让我们显示当前的行号,并将其右对齐到屏幕的边缘。

src/editor.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@@ -216,9 +216,16 @@ impl Editor {
file_name.truncate(20);
}
status = format!("{} - {} lines", file_name, self.document.len());
- if width > status.len() {
- status.push_str(&" ".repeat(width - status.len()));
+ let line_indicator = format!(
+ "{}/{}",
+ self.cursor_position.y.saturating_add(1),
+ self.document.len()
+ );
+ let len = status.len() + line_indicator.len();
+ if width > len {
+ status.push_str(&" ".repeat(width - len));
}
+ status = format!("{}{}", status, line_indicator);
status.truncate(width);
Terminal::set_bg_color(STATUS_BG_COLOR);
Terminal::set_fg_color(STATUS_FG_COLOR);

需要注意,当前行号存储在cursor_position.y中,我们需要给它加1,因为位置索引是从0开始的。

状态信息

我们将在状态栏下方再添加一行。这将用于向用户显示消息,并在进行搜索时提示用户输入。我们会在一个名为StatusMessage的结构体中存储当前消息,并将其放入编辑器状态中。我们还会为消息存储一个时间戳,以便在显示几秒钟后可以将其擦除。

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
@@ -2,6 +2,8 @@ use crate::Document;
use crate::Row;
use crate::Terminal;
use std::env;
+use std::time::Duration;
+use std::time::Instant;
use termion::color;
use termion::event::Key;

@@ -14,12 +16,27 @@ pub struct Position {
pub x: usize,
pub y: usize,
}
+
+struct StatusMessage {
+ text: String,
+ time: Instant,
+}
+
+impl StatusMessage {
+ fn form(message: String) -> Self {
+ Self {
+ time: Instant::now(),
+ text: message,
+ }
+ }
+}
pub struct Editor {
should_quit: bool,
terminal: Terminal,
cursor_position: Position,
document: Document,
offset: Position,
+ status_message: StatusMessage,
}

impl Editor {
@@ -39,9 +56,16 @@ impl Editor {

pub fn default() -> Self {
let args: Vec<String> = env::args().collect();
+ let mut initial_status = String::from("HELP: Ctrl-Q = quit");
let document = if args.len() > 1 {
let file_name = &args[1];
- Document::open(&file_name).unwrap_or_default()
+ let doc = Document::open(&file_name);
+ if doc.is_ok() {
+ doc.unwrap()
+ } else {
+ initial_status = format!("ERRROR: Could not open file {}", file_name);
+ Document::default()
+ }
} else {
Document::default()
};
@@ -52,6 +76,7 @@ impl Editor {
cursor_position: Position::default(),
document,
offset: Position::default(),
+ status_message: StatusMessage::form(initial_status),
}
}

@@ -235,6 +260,12 @@ impl Editor {
}
fn draw_message_bar(&self) {
Terminal::clear_current_line();
+ let message = &self.status_message;
+ if Instant::now() - message.time < Duration::new(5, 0) {
+ let mut text = message.text.clone();
+ text.truncate(self.terminal.size().width as usize);
+ print!("{}", text);
+ }
}
}

我们初始化status_message为一个帮助信息,显示键绑定。我们还利用这个机会设置状态消息为一个错误信息,当我们无法打开文件时。我们需要对打开文档的代码稍作调整,以便正确的文档被加载和正确的状态消息被设置。

现在我们有了一个要显示的状态消息,我们可以修改draw_message_bar()函数。首先我们用Terminal::clear_current_line()清除消息栏。我们不需要为状态栏做这件事,因为我们总是在每次渲染时重写整行。然后我们确保消息适应屏幕宽度,并且只有当消息不超过5秒时才显示。这意味着即使我们不再显示旧的状态消息,我们也会保留它。这是可以的,因为那个数据结构本来就很小,而且不会随时间增长。

现在当启动程序时,会在底部看到帮助信息,如果在显示5秒后按下一个键,它将消失。因为在每次按键后都会刷新屏幕。

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


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