本文最后更新于: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
中,String
和str
是有区别的。
在Document
中,我们添加了一个方法来检索特定索引处的Row
。我们使用Vector
的get
方法:如果索引超出范围则返回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
,因为Rust
的line()
方法会剪切掉它们。
现在让我们实际使用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) }
我们将偏移量添加到字符串的开始和结束位置,以获得我们所需的字符串切片。我们也确保了在字符串长度不足以填充屏幕时的情况能得到处理。如果当前行在屏幕左侧结束(如果我们在一行很长的行中滚动到右侧时可能会发生),Row
的render
方法将返回一个空字符串。
我们在哪里设置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 Up
、Page Down
翻页
现在我们已经实现了滚动功能,让我们设置Page Up
和Page 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
或者其他特殊符号的话
当你在该文件中滚动时,你会注意到只有第一行能够正确地向右滚动,其他所有行都会让你滚动到行尾之外。
这是由于某些符号会占用多个字节。比如,德语元音变音符号,如ä
会返回多个字节。我们可以通过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-start
个Grapheme
(即可见部分)。这些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的终端只支持16
个ANIS
颜色,可以进行替换,如:
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