Hecto, Chapter 4: A text viewer

Hecto, Chapter 4: A text viewer

Previous chapter - Overview - Appendices - Next Chapter

Let’s see if we can turn hecto into a text viewer in this chapter.

A line viewer

We need a few more data structures: A Document which will represent the document the user is currently editing, as well as a Row in that document.

src/document.rs ADDED
@@ -0,0 +1,6 @@
1
+ use crate::Row;
2
+
3
+ #[derive(Default)]
4
+ pub struct Document {
5
+ rows: Vec<Row>,
6
+ }
src/editor.rs CHANGED
@@ -1,3 +1,4 @@
1
+ use crate::Document;
1
2
  use crate::Terminal;
2
3
  use termion::event::Key;
3
4
 
@@ -12,6 +13,7 @@ pub struct Editor {
12
13
  should_quit: bool,
13
14
  terminal: Terminal,
14
15
  cursor_position: Position,
16
+ document: Document,
15
17
  }
16
18
 
17
19
  impl Editor {
@@ -32,6 +34,7 @@ impl Editor {
32
34
  Self {
33
35
  should_quit: false,
34
36
  terminal: Terminal::default().expect("Failed to initialize terminal"),
37
+ document: Document::default(),
35
38
  cursor_position: Position { x: 0, y: 0 },
36
39
  }
37
40
  }
src/main.rs CHANGED
@@ -1,9 +1,13 @@
1
1
  #![warn(clippy::all, clippy::pedantic)]
2
+ mod document;
2
3
  mod editor;
4
+ mod row;
3
5
  mod terminal;
6
+ pub use document::Document;
4
7
  use editor::Editor;
5
- pub use terminal::Terminal;
6
8
  pub use editor::Position;
9
+ pub use row::Row;
10
+ pub use terminal::Terminal;
7
11
 
8
12
  fn main() {
9
13
  Editor::default().run();
src/row.rs ADDED
@@ -0,0 +1,3 @@
1
+ pub struct Row {
2
+ string: String
3
+ }

See this step on github

In this change, we have introduced two new concepts to our code: First, we are using a data structure called a Vector which will hold our rows. A Vector is a dynamic structure: It can grow and shrink on runtime, as we are adding to or removing from it. The syntax Vec<Row> means that this vector will hold entries of the type Row.

The other new concept is this line:

#[derive(Default)]

It means that the rust compiler is supposed to derive an implementation for default. default is supposed to return a struct with its contents initialized to a default value - which is something that the compiler can do for us. With that directive, we do not need to implement default ourselves. Let’s see if we can simplify our existing code with it:

src/editor.rs CHANGED
@@ -4,6 +4,7 @@ use termion::event::Key;
4
4
 
5
5
  const VERSION: &str = env!("CARGO_PKG_VERSION");
6
6
 
7
+ #[derive(Default)]
7
8
  pub struct Position {
8
9
  pub x: usize,
9
10
  pub y: usize,
@@ -35,13 +36,13 @@ impl Editor {
35
36
  should_quit: false,
36
37
  terminal: Terminal::default().expect("Failed to initialize terminal"),
37
38
  document: Document::default(),
38
- cursor_position: Position { x: 0, y: 0 },
39
+ cursor_position: Position::default(),
39
40
  }
40
41
  }
41
42
 
42
43
  fn refresh_screen(&self) -> Result<(), std::io::Error> {
43
44
  Terminal::cursor_hide();
44
- Terminal::cursor_position(&Position { x: 0, y: 0 });
45
+ Terminal::cursor_position(&Position::default());
45
46
  if self.should_quit {
46
47
  Terminal::clear_screen();
47
48
  println!("Goodbye.\r");

See this step on github

By deriving default for Position, we have removed the duplication of initializing the cursor position to 0, 0. If, in the future, we would decide to intialize Position in a different way, then we could implement default ourselves without needint to touch any other code.

We can’t derive default for our other structs - these are too complex, Rust can not guess the default values for all struct members.

Let’s fill the Document with some text now. We won’t worry about reading from a file just yet. Instead, we’ll hard code a “Hello, World” string into it.

src/document.rs CHANGED
@@ -4,3 +4,11 @@ use crate::Row;
4
4
  pub struct Document {
5
5
  rows: Vec<Row>,
6
6
  }
7
+
8
+ impl Document {
9
+ pub fn open() -> Self {
10
+ let mut rows = Vec::new();
11
+ rows.push(Row::from("Hello, World!"));
12
+ Self { rows }
13
+ }
14
+ }
src/editor.rs CHANGED
@@ -35,7 +35,7 @@ impl Editor {
35
35
  Self {
36
36
  should_quit: false,
37
37
  terminal: Terminal::default().expect("Failed to initialize terminal"),
38
- document: Document::default(),
38
+ document: Document::open(),
39
39
  cursor_position: Position::default(),
40
40
  }
41
41
  }
src/row.rs CHANGED
@@ -1,3 +1,11 @@
1
1
  pub struct Row {
2
- string: String
3
- }
2
+ string: String,
3
+ }
4
+
5
+ impl From<&str> for Row {
6
+ fn from(slice: &str) -> Self {
7
+ Self {
8
+ string: String::from(slice),
9
+ }
10
+ }
11
+ }

See this step on github

You might be wondering about the From<&str> part in the impl block for the row. We are now not only implementing a from function, but we do so by implementing the From trait for Row. We won’t need it in the scope of this tutorial, but implementing a trait enables us to use certain functionalities in a certain way. We will handle traits a bit later in even more detail, but if you are interested now, check out this part of the docs - we get into for free while implementing from.

We will later implement a method to open a Document from file. At that point, we will use default again when initializing our editor. But let’s focus on getting our hardcoded value displayed for now.

src/document.rs CHANGED
@@ -11,4 +11,7 @@ impl Document {
11
11
  rows.push(Row::from("Hello, World!"));
12
12
  Self { rows }
13
13
  }
14
+ pub fn row(&self, index: usize) -> Option<&Row> {
15
+ self.rows.get(index)
16
+ }
14
17
  }
src/editor.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  use crate::Document;
2
+ use crate::Row;
2
3
  use crate::Terminal;
3
4
  use termion::event::Key;
4
5
 
@@ -105,11 +106,19 @@ impl Editor {
105
106
  welcome_message.truncate(width);
106
107
  println!("{}\r", welcome_message);
107
108
  }
109
+ pub fn draw_row(&self, row: &Row) {
110
+ let start = 0;
111
+ let end = self.terminal.size().width as usize;
112
+ let row = row.render(start, end);
113
+ println!("{}\r", row)
114
+ }
108
115
  fn draw_rows(&self) {
109
116
  let height = self.terminal.size().height;
110
- for row in 0..height - 1 {
117
+ for terminal_row in 0..height - 1 {
111
118
  Terminal::clear_current_line();
112
- if row == height / 3 {
119
+ if let Some(row) = self.document.row(terminal_row as usize) {
120
+ self.draw_row(row);
121
+ } else if terminal_row == height / 3 {
113
122
  self.draw_welcome_message();
114
123
  } else {
115
124
  println!("~\r");
src/row.rs CHANGED
@@ -1,3 +1,5 @@
1
+ use std::cmp;
2
+
1
3
  pub struct Row {
2
4
  string: String,
3
5
  }
@@ -9,3 +11,11 @@ impl From<&str> for Row {
9
11
  }
10
12
  }
11
13
  }
14
+
15
+ impl Row {
16
+ pub fn render(&self, start: usize, end: usize) -> String {
17
+ let end = cmp::min(end, self.string.len());
18
+ let start = cmp::min(start, end);
19
+ self.string.get(start..end).unwrap_or_default().to_string()
20
+ }
21
+ }

See this step on github

Let’s unravel this change starting with Row. We have added a method called render. We call it render, because eventually it will be responsible for a few more things than just returning a substring. Our render method is very user friendly as it normalizes bogus input - essentially, it returns the biggest possible substring it can generate. We’re also routinely using unwrap_or_default, even though it’s not necessary here, as we sanitized the start and end parameters beforehand. What happens in the last line is that we try to create a substring from the string and either convert it or the default value ("") to a string. (In Rust, there is a difference between a String and something called a str. We will get to this soon.)

In Document, we add a method to retrieve a Row at a specific index. We use Vector’s get for this, which has the signature we need: Return None if the index is out of bounds, or the Row if we have one.

Let’s move to Editor. In draw_rows, we first rename the variable row to terminal_row to avoid confusion with the row we are now getting from Document. We are then retrieving the row and displaying it. The concept here is that Row makes sure to return to you a substring that can be displayed, while the Editor makes sure that the terminal dimensions are met.

However, our welcome message is still displayed. We don’t want that when the user is opening a file, so let’s add a method is_empty to our Document and check against it in draw_rows:

src/document.rs CHANGED
@@ -14,4 +14,7 @@ impl Document {
14
14
  pub fn row(&self, index: usize) -> Option<&Row> {
15
15
  self.rows.get(index)
16
16
  }
17
+ pub fn is_empty(&self) -> bool {
18
+ self.rows.is_empty()
19
+ }
17
20
  }
src/editor.rs CHANGED
@@ -118,7 +118,7 @@ impl Editor {
118
118
  Terminal::clear_current_line();
119
119
  if let Some(row) = self.document.row(terminal_row as usize) {
120
120
  self.draw_row(row);
121
- } else if terminal_row == height / 3 {
121
+ } else if self.document.is_empty() && terminal_row == height / 3 {
122
122
  self.draw_welcome_message();
123
123
  } else {
124
124
  println!("~\r");

See this step on github

You should be able to confirm that the message is no longer shown in the middle of the screen.

Next, let’s allow the user to open and display actual file. We start by changing our Document:

src/document.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  use crate::Row;
2
+ use std::fs;
2
3
 
3
4
  #[derive(Default)]
4
5
  pub struct Document {
@@ -6,10 +7,15 @@ pub struct Document {
6
7
  }
7
8
 
8
9
  impl Document {
9
- pub fn open() -> Self {
10
+ pub fn open(filename: &str ) -> Result<Self, std::io::Error> {
11
+ let contents = fs::read_to_string(filename)?;
10
12
  let mut rows = Vec::new();
11
- rows.push(Row::from("Hello, World!"));
12
- Self { rows }
13
+ for value in contents.lines() {
14
+ rows.push(Row::from(value));
15
+ }
16
+ Ok(Self{
17
+ rows
18
+ })
13
19
  }
14
20
  pub fn row(&self, index: usize) -> Option<&Row> {
15
21
  self.rows.get(index)
src/editor.rs CHANGED
@@ -36,7 +36,7 @@ impl Editor {
36
36
  Self {
37
37
  should_quit: false,
38
38
  terminal: Terminal::default().expect("Failed to initialize terminal"),
39
- document: Document::open(),
39
+ document: Document::default(),
40
40
  cursor_position: Position::default(),
41
41
  }
42
42
  }

See this step on github

We are now using a default Document on start, and added a new method open, which attempts to open a file and returns an error in case of a failure.

open reads the lines into our Document struct. It’s not obvious from our code, but each row in rows will not contain the line endings \n or \r\n, as Rust’s line() method will cut it away for us. That makes sense: We are already handling new lines ourselves, so we wouldn’t want to handle the ones in the file anyways.

Let’s now actually use open to open a file which will be passed to hecto by command line:

src/editor.rs CHANGED
@@ -1,6 +1,7 @@
1
1
  use crate::Document;
2
2
  use crate::Row;
3
3
  use crate::Terminal;
4
+ use std::env;
4
5
  use termion::event::Key;
5
6
 
6
7
  const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -33,10 +34,18 @@ impl Editor {
33
34
  }
34
35
  }
35
36
  pub fn default() -> Self {
37
+ let args: Vec<String> = env::args().collect();
38
+ let document = if args.len() > 1 {
39
+ let file_name = &args[1];
40
+ Document::open(&file_name).unwrap_or_default()
41
+ } else {
42
+ Document::default()
43
+ };
44
+
36
45
  Self {
37
46
  should_quit: false,
38
47
  terminal: Terminal::default().expect("Failed to initialize terminal"),
39
- document: Document::default(),
48
+ document,
40
49
  cursor_position: Position::default(),
41
50
  }
42
51
  }

See this step on github

Try it out by running cargo run in contrast to cargo run Cargo.toml!

Here are a few things to observe. First, we can use if..else as a statement - in this case, it means that the result of either block of the if statement is bound to document. To make this work, we have to omit ; on the last line of each block of the if, but add a ; after the last closing }. This ensures that document is never undefined.

Since we have implemented default on Document, we can use unwrap_or_default here. What this does is that in case open yielded an error, a default document will be returned, the error will be discarded (we will rectify this later though)

We are calling Document::open() only if we have more than one arg. args is a vector which contains the command line parameters which have been passed to our program. Per convention, args[0] is always the name of our program, so args[1] contains the parameter we’re after - we want to use hecto (filename) to open a file. You can pass a file name to your program by running cargo run (filename) while you are developing.

Now you should see your screen fill up with lines of text when you run cargo run Cargo.toml, for example.

Scrolling

Next we want to enable the user to scroll through the whole file, instead of just being able to see the top few lines of the file. Let’s add an offset to the editor state, which will keep track of what row of the file the user is currently scrolled to. We are reusing the Position struct for that.

src/editor.rs CHANGED
@@ -16,6 +16,7 @@ pub struct Editor {
16
16
  should_quit: bool,
17
17
  terminal: Terminal,
18
18
  cursor_position: Position,
19
+ offset: Position,
19
20
  document: Document,
20
21
  }
21
22
 
@@ -47,6 +48,7 @@ impl Editor {
47
48
  terminal: Terminal::default().expect("Failed to initialize terminal"),
48
49
  document,
49
50
  cursor_position: Position::default(),
51
+ offset: Position::default(),
50
52
  }
51
53
  }
52
54
 

See this step on github

We initialize it with the default value. which means we’ll be scrolled to the top left of the file by default.

Now let’s have draw_row() display the correct range of lines of the file according to the value of offset.x, and draw_rows display the correct range of rows according to the value of offset.y.

src/editor.rs CHANGED
@@ -118,8 +118,9 @@ impl Editor {
118
118
  println!("{}\r", welcome_message);
119
119
  }
120
120
  pub fn draw_row(&self, row: &Row) {
121
- let start = 0;
122
- let end = self.terminal.size().width as usize;
121
+ let width = self.terminal.size().width as usize;
122
+ let start = self.offset.x;
123
+ let end = self.offset.x + width;
123
124
  let row = row.render(start, end);
124
125
  println!("{}\r", row)
125
126
  }
@@ -127,7 +128,7 @@ impl Editor {
127
128
  let height = self.terminal.size().height;
128
129
  for terminal_row in 0..height - 1 {
129
130
  Terminal::clear_current_line();
130
- if let Some(row) = self.document.row(terminal_row as usize) {
131
+ if let Some(row) = self.document.row(terminal_row as usize + self.offset.y) {
131
132
  self.draw_row(row);
132
133
  } else if self.document.is_empty() && terminal_row == height / 3 {
133
134
  self.draw_welcome_message();

See this step on github

We are adding the offset to the start and to the end, to get the slice of the string we’re after. We are also making sure that we can handle situations where our string is not long enough to fit the screen. If the current row has ended to the left of the current screen (which can happen if we are in a long row and scroll to the right), the render method of Row will return an empty string.

Where do we set the value of offset? Our strategy will be to check if the cursor has moved outside of the visible window, and if so, adjust offset so that the cursor is just inside the visible window. We’ll put this logic in a function called scroll, and call it right after we handled the key press.

src/editor.rs CHANGED
@@ -79,8 +79,25 @@ impl Editor {
79
79
  | Key::Home => self.move_cursor(pressed_key),
80
80
  _ => (),
81
81
  }
82
+ self.scroll();
82
83
  Ok(())
83
84
  }
85
+ fn scroll(&mut self) {
86
+ let Position { x, y } = self.cursor_position;
87
+ let width = self.terminal.size().width as usize;
88
+ let height = self.terminal.size().height as usize;
89
+ let mut offset = &mut self.offset;
90
+ if y < offset.y {
91
+ offset.y = y;
92
+ } else if y >= offset.y.saturating_add(height) {
93
+ offset.y = y.saturating_sub(height).saturating_add(1);
94
+ }
95
+ if x < offset.x {
96
+ offset.x = x;
97
+ } else if x >= offset.x.saturating_add(width) {
98
+ offset.x = x.saturating_sub(width).saturating_add(1);
99
+ }
100
+ }
84
101
  fn move_cursor(&mut self, key: Key) {
85
102
  let Position { mut y, mut x } = self.cursor_position;
86
103
  let size = self.terminal.size();

See this step on github

To scroll, we need to know the width and height of the terminal and the current position, and we want to change the values in self.offset. If we have moved to the left or to the top, we want to set our offset to the new position in the document. If we have scrolled too far to the right, we are subtracting the current offset from the new position to calculate the new offset.

Now let’s allow the cursor to advance past the bottom of the screen (but not past the bottom of the file). We tackle scrolling to the right a bit later.

src/document.rs CHANGED
@@ -23,4 +23,7 @@ impl Document {
23
23
  pub fn is_empty(&self) -> bool {
24
24
  self.rows.is_empty()
25
25
  }
26
+ pub fn len(&self) -> usize {
27
+ self.rows.len()
28
+ }
26
29
  }
src/editor.rs CHANGED
@@ -101,7 +101,7 @@ impl Editor {
101
101
  fn move_cursor(&mut self, key: Key) {
102
102
  let Position { mut y, mut x } = self.cursor_position;
103
103
  let size = self.terminal.size();
104
- let height = size.height.saturating_sub(1) as usize;
104
+ let height = self.document.len();
105
105
  let width = size.width.saturating_sub(1) as usize;
106
106
  match key {
107
107
  Key::Up => y = y.saturating_sub(1),

See this step on github

You should be able to scroll through the entire file now, when you run cargo run src/editor.rs. The handling of the last line will be a bit strange, since we place our cursor there, but are not rendering there. This will be fixed when we add the status bar later in this chapter. If you try to scroll back up, you may notice the cursor isn’t being positioned properly. That is because Position in the state no longer refers to the position of the cursor on the screen. It refers to the position of the cursor within the text file, but we are still passing it to cursor_position. To position the cursor on the screen, we now have to subtract the offset from the position within the document.

src/editor.rs CHANGED
@@ -60,7 +60,10 @@ impl Editor {
60
60
  println!("Goodbye.\r");
61
61
  } else {
62
62
  self.draw_rows();
63
- Terminal::cursor_position(&self.cursor_position);
63
+ Terminal::cursor_position(&Position {
64
+ x: self.cursor_position.x.saturating_sub(self.offset.x),
65
+ y: self.cursor_position.y.saturating_sub(self.offset.y),
66
+ });
64
67
  }
65
68
  Terminal::cursor_show();
66
69
  Terminal::flush()

See this step on github

Now let’s fix the horizontal scrolling. The missing piece here is that we are not yet allowing the cursor to scroll past the right of the screen. We fix that symmetrical to what we did for scrolling down:

src/editor.rs CHANGED
@@ -103,9 +103,12 @@ impl Editor {
103
103
  }
104
104
  fn move_cursor(&mut self, key: Key) {
105
105
  let Position { mut y, mut x } = self.cursor_position;
106
- let size = self.terminal.size();
107
106
  let height = self.document.len();
108
- let width = size.width.saturating_sub(1) as usize;
107
+ let width = if let Some(row) = self.document.row(y) {
108
+ row.len()
109
+ } else {
110
+ 0
111
+ };
109
112
  match key {
110
113
  Key::Up => y = y.saturating_sub(1),
111
114
  Key::Down => {
src/row.rs CHANGED
@@ -16,6 +16,12 @@ impl Row {
16
16
  pub fn render(&self, start: usize, end: usize) -> String {
17
17
  let end = cmp::min(end, self.string.len());
18
18
  let start = cmp::min(start, end);
19
- self.string.get(start..end).unwrap_or_default().to_string()
19
+ self.string.get(start..end).unwrap_or_default().to_string()
20
+ }
21
+ pub fn len(&self) -> usize {
22
+ self.string.len()
23
+ }
24
+ pub fn is_empty(&self) -> bool {
25
+ self.string.is_empty()
20
26
  }
21
27
  }

See this step on github

All we had to do is changing the width used by move_cursor. Horizontal scrolling does now work. In case you are wondering, it’s a best practice to implement is_empty as soon as you have a len function. We’re not using it for now, but Clippy pointed it out, and it was easy for us to implement.

Our scrolling code still contains a subtle bug, which we will fix after a few other improvements. Can you spot it?

Snap cursor to end of line

Now cursor_position refers to the cursor’s position within the file, not its position on the screen. So our goal with the next few steps is to limit the values of cursor_position to only ever point to valid positions in the file, with the exception that we allow the cursor to point one character past the end of a line or past the end of the file, so that the user can add new characters at the end of a line, and new lines at the end of the file can be added easily.

We are already able to prevent the user from scrolling too far to the right or too far down. The user is still able to move the cursor past the end of a line, however. They can do it by moving the cursor to the end of a long line, then moving it down to the next line, which is shorter. The cursor_position.y value won’t change, and the cursor will be off to the right of the end of the line it’s now on.

Let’s add some code to move_cursor() that corrects cursor_position if it ends up past the end of the line it’s on.

src/editor.rs CHANGED
@@ -104,7 +104,7 @@ impl Editor {
104
104
  fn move_cursor(&mut self, key: Key) {
105
105
  let Position { mut y, mut x } = self.cursor_position;
106
106
  let height = self.document.len();
107
- let width = if let Some(row) = self.document.row(y) {
107
+ let mut width = if let Some(row) = self.document.row(y) {
108
108
  row.len()
109
109
  } else {
110
110
  0
@@ -128,6 +128,15 @@ impl Editor {
128
128
  Key::End => x = width,
129
129
  _ => (),
130
130
  }
131
+ width = if let Some(row) = self.document.row(y) {
132
+ row.len()
133
+ } else {
134
+ 0
135
+ };
136
+ if x > width {
137
+ x = width;
138
+ }
139
+
131
140
  self.cursor_position = Position { x, y }
132
141
  }
133
142
  fn draw_welcome_message(&self) {

See this step on github

We have to set width again, since row can have changed during the key processing. We then set the new value of x, making sure that x does not exceed the current row’s width.

Scrolling with Page Up and Page Down

Now that we have scrolling, let’s make the Page Up and Page Down keys scroll up or down an entire page instead of the full document.

src/editor.rs CHANGED
@@ -102,6 +102,7 @@ impl Editor {
102
102
  }
103
103
  }
104
104
  fn move_cursor(&mut self, key: Key) {
105
+ let terminal_height = self.terminal.size().height as usize;
105
106
  let Position { mut y, mut x } = self.cursor_position;
106
107
  let height = self.document.len();
107
108
  let mut width = if let Some(row) = self.document.row(y) {
@@ -122,8 +123,20 @@ impl Editor {
122
123
  x = x.saturating_add(1);
123
124
  }
124
125
  }
125
- Key::PageUp => y = 0,
126
- Key::PageDown => y = height,
126
+ Key::PageUp => {
127
+ y = if y > terminal_height {
128
+ y - terminal_height
129
+ } else {
130
+ 0
131
+ }
132
+ }
133
+ Key::PageDown => {
134
+ y = if y.saturating_add(terminal_height) < height {
135
+ y + terminal_height as usize
136
+ } else {
137
+ height
138
+ }
139
+ }
127
140
  Key::Home => x = 0,
128
141
  Key::End => x = width,
129
142
  _ => (),

See this step on github

We were able to get rid of unnecessary saturating arithmetics. Why? For example, y and height have the same type. If y.saturating_add(terminal_height) is less than height, then y + terminal_height is also less than height.

If we try this out now, we can see that we still have some issues with the last line. Instead of moving to the next screen on Page Down, our cursor lands at the empty row at the bottom. We’ll fix this at the end of this chapter

  • but before we do, let’s complete our cursor navigation in this file.

Moving left at the start of a line

We want to allow the user to press at the beginning of the line to move to the end of the previous line.

src/editor.rs CHANGED
@@ -117,7 +117,18 @@ impl Editor {
117
117
  y = y.saturating_add(1);
118
118
  }
119
119
  }
120
- Key::Left => x = x.saturating_sub(1),
120
+ Key::Left => {
121
+ if x > 0 {
122
+ x -= 1;
123
+ } else if y > 0 {
124
+ y -= 1;
125
+ if let Some(row) = self.document.row(y) {
126
+ x = row.len();
127
+ } else {
128
+ x = 0;
129
+ }
130
+ }
131
+ }
121
132
  Key::Right => {
122
133
  if x < width {
123
134
  x = x.saturating_add(1);

We make sure they aren’t on the very first line before we move them up a line. We don’t need to use saturating_sub any more, as we check explicitly if the value we want to subtract from is bigger than 0.

Moving right at the end of a line

Similarly, let’s allow the user to press at the end of a line to go to the beginning of the next line.

src/editor.rs CHANGED
@@ -131,7 +131,10 @@ impl Editor {
131
131
  }
132
132
  Key::Right => {
133
133
  if x < width {
134
- x = x.saturating_add(1);
134
+ x += 1;
135
+ } else if y < height {
136
+ y += 1;
137
+ x = 0;
135
138
  }
136
139
  }
137
140
  Key::PageUp => {

See this step on github

Here we have to make sure they’re not at the end of the file before moving down a line. We were also able to remove saturated_add here. height and y are of the same type, so if y is smaller than height, then we have enough room to add 1 to it.

Fix scrolling

Now, let’s focus on the bug that I hinted at above. To reproduce it, save the following as a text file and open it with hecto:

aaa
äää
y̆y̆y̆
❤❤❤

When you scroll around in that file, you will notice that only the first line scrolls correctly to the right, all other lines let you scroll past the end of the line.

One of our first steps in this tutorial was to observe the bytes returned to us on every key press. We observed that German Umlauts such as ä return multiple bytes, and that’s exactly what’s causing the bug.

Let’s observe the behavior in more detail. Don’t worry, we’re not going to revert our code to observe key presses again, instead we’re going to head over to the Rust playground and paste in the following code:

fn main() {
    dbg!("aaa".to_string().len());
    dbg!("äää".to_string().len());
    dbg!("y̆y̆y̆".to_string().len());
    dbg!("❤❤❤".to_string().len());
}

dbg! is a macro which is useful for quick and dirty debugging, it prints out the current value of what you give in, and more. Here’s what it returns for that code:

[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

We can see now that the length of the string can be bigger than what we thought would be the length of the string, as some characters simply take up more than one byte, or are a composition of more than one character. For instance, the Female Scientist Emoji (👩‍🔬) is a combination of the Woman Emoji (👩) and the Microscope Emoji (🔬). Wrong handling of these emojis can lead to interesting results.

So what is the length of a row for us? Fundamentally, it’s one element on the screen that our mouse can move over. That is called a Grapheme, and Rust, unlike some other languages, does not support Graphemes by default. That means that we either need to do the coding ourselves, or we use a crate which does this for us. Since I’m not in the mood for reinventing the wheel, let’s use a crate for that.

Cargo.toml CHANGED
@@ -7,4 +7,5 @@ edition = "2018"
7
7
  # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8
8
 
9
9
  [dependencies]
10
- termion = "1"
10
+ termion = "1"
11
+ unicode-segmentation = "1"
src/row.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  use std::cmp;
2
+ use unicode_segmentation::UnicodeSegmentation;
2
3
 
3
4
  pub struct Row {
4
5
  string: String,
@@ -16,10 +17,18 @@ impl Row {
16
17
  pub fn render(&self, start: usize, end: usize) -> String {
17
18
  let end = cmp::min(end, self.string.len());
18
19
  let start = cmp::min(start, end);
19
- self.string.get(start..end).unwrap_or_default().to_string()
20
+ let mut result = String::new();
21
+ for grapheme in self.string[..]
22
+ .graphemes(true)
23
+ .skip(start)
24
+ .take(end - start)
25
+ {
26
+ result.push_str(grapheme);
27
+ }
28
+ result
20
29
  }
21
30
  pub fn len(&self) -> usize {
22
- self.string.len()
31
+ self.string[..].graphemes(true).count()
23
32
  }
24
33
  pub fn is_empty(&self) -> bool {
25
34
  self.string.is_empty()

See this step on github

We have introduced two changes. In both cases, we are performing graphemes() on the slice of the full String (indicated by [..]) and then use that iterator. In the case of len(), we are calling count() on the iterator, which tells us how many graphemes there are. In case of render, we are now starting to build our own string instead of using the built-in methods. For that, we skip the first graphemes (the ones to the left of the screen), and we only take end-start many graphemes (the visible portion of the row). These graphemes are then pushed into the return value.

While this works, the performance is not optimal. count actually goes through the whole iterator and then returns the value. This means that for every visible row, we are repeatedly counting the length of the full row. Let’s keep track of the length ourselves instead.

src/row.rs CHANGED
@@ -3,13 +3,17 @@ use unicode_segmentation::UnicodeSegmentation;
3
3
 
4
4
  pub struct Row {
5
5
  string: String,
6
+ len: usize,
6
7
  }
7
8
 
8
9
  impl From<&str> for Row {
9
10
  fn from(slice: &str) -> Self {
10
- Self {
11
+ let mut row = Self {
11
12
  string: String::from(slice),
12
- }
13
+ len: 0,
14
+ };
15
+ row.update_len();
16
+ row
13
17
  }
14
18
  }
15
19
 
@@ -28,9 +32,12 @@ impl Row {
28
32
  result
29
33
  }
30
34
  pub fn len(&self) -> usize {
31
- self.string[..].graphemes(true).count()
35
+ self.len
32
36
  }
33
37
  pub fn is_empty(&self) -> bool {
34
- self.string.is_empty()
38
+ self.len == 0
39
+ }
40
+ fn update_len(&mut self) {
41
+ self.len = self.string[..].graphemes(true).count();
35
42
  }
36
43
  }

See this step on github

Now we only have to remember to call update_len whenever our row changes. Scrolling works now - or does it? Turns out we have to jump through one more hoop to get all of it right.

Rendering Tabs

If you try opening a file with tabs, you’ll notice that the tab character takes up a width of 8 columns or so. As you probably know, there is a long and ongoing debate of whether or not to use tabs or spaces for indentation. Honestly, I don’t care, I have always a sufficiently advanced editor that could just roll with any indentation type you throw at it. If I was forced to pick a side, I would pick “spaces”, though, because I find the “pros” for tabs not very convincing - but that’s a different matter. What matters, though, is that we are simply replacing tabs with one space for the sake of this tutorial. That’s enough for our purpose, as in the Rust ecosystem, you will rarely encounter tabs.

Let’s replace our tabs with spaces now.

src/row.rs CHANGED
@@ -27,7 +27,11 @@ impl Row {
27
27
  .skip(start)
28
28
  .take(end - start)
29
29
  {
30
- result.push_str(grapheme);
30
+ if grapheme == "\t" {
31
+ result.push_str(" ");
32
+ } else {
33
+ result.push_str(grapheme);
34
+ }
31
35
  }
32
36
  result
33
37
  }

See this step on github

That’s it - we have finally solved all the edge cases we care about. Now let’s tackle one last issue which has been bugging us for quite some time: Let’s fix that last line.

Status bar

The last thing we’ll add before finally getting to text editing is a status bar. This will show useful information such as the filename, how many lines are in the file, and what line you’re currently on. Later we’ll add a marker that tells you whether the file has been modified since it was last saved, and we’ll also display the filetype when we implement syntax highlighting.

First we’ll simply make room for a two-line status bar at the bottom of the screen. We will now also fix the issues we have with the rendering of the last line.

src/editor.rs CHANGED
@@ -185,7 +185,7 @@ impl Editor {
185
185
  }
186
186
  fn draw_rows(&self) {
187
187
  let height = self.terminal.size().height;
188
- for terminal_row in 0..height - 1 {
188
+ for terminal_row in 0..height {
189
189
  Terminal::clear_current_line();
190
190
  if let Some(row) = self.document.row(terminal_row as usize + self.offset.y) {
191
191
  self.draw_row(row);
src/terminal.rs CHANGED
@@ -19,7 +19,7 @@ impl Terminal {
19
19
  Ok(Self {
20
20
  size: Size {
21
21
  width: size.0,
22
- height: size.1,
22
+ height: size.1.saturating_sub(2),
23
23
  },
24
24
  _stdout: stdout().into_raw_mode()?,
25
25
  })

See this step on github

You should now be able to confirm that two lines are cleared at the bottom and that Page Up and Down works as expected.

Notice how with this change, our text viewer works just fine, including scrolling and cursor movement, and the last lines where our status bar will be are left alone by the rest of the display code.

To make the status bar stand out, we’re going to display it colored. termion takes care of the corresponding escape sequences for us, so we don’t have to do this manually. The corresponding escape sequence is the m command (Select Graphic Rendition). The VT100 User Guide doesn’t document color, so let’s turn to the Wikipedia article on ANSI escape codes. It includes a large table containing all the different argument codes you can use with the m command on various terminals. It also includes the ANSI color table with the 8 foreground/background colors available.

We’re going to use termion’s capability to provide RGB colors, which fall back to simpler colors in case they are not supported by the current terminal.

src/editor.rs CHANGED
@@ -2,8 +2,10 @@ use crate::Document;
2
2
  use crate::Row;
3
3
  use crate::Terminal;
4
4
  use std::env;
5
+ use termion::color;
5
6
  use termion::event::Key;
6
7
 
8
+ const STATUS_BG_COLOR: color::Rgb = color::Rgb(239, 239, 239);
7
9
  const VERSION: &str = env!("CARGO_PKG_VERSION");
8
10
 
9
11
  #[derive(Default)]
@@ -60,6 +62,8 @@ impl Editor {
60
62
  println!("Goodbye.\r");
61
63
  } else {
62
64
  self.draw_rows();
65
+ self.draw_status_bar();
66
+ self.draw_message_bar();
63
67
  Terminal::cursor_position(&Position {
64
68
  x: self.cursor_position.x.saturating_sub(self.offset.x),
65
69
  y: self.cursor_position.y.saturating_sub(self.offset.y),
@@ -196,6 +200,15 @@ impl Editor {
196
200
  }
197
201
  }
198
202
  }
203
+ fn draw_status_bar(&self) {
204
+ let spaces = " ".repeat(self.terminal.size().width as usize);
205
+ Terminal::set_bg_color(STATUS_BG_COLOR);
206
+ println!("{}\r", spaces);
207
+ Terminal::reset_bg_color();
208
+ }
209
+ fn draw_message_bar(&self) {
210
+ Terminal::clear_current_line();
211
+ }
199
212
  }
200
213
 
201
214
  fn die(e: std::io::Error) {
src/terminal.rs CHANGED
@@ -3,6 +3,7 @@ use std::io::{self, stdout, Write};
3
3
  use termion::event::Key;
4
4
  use termion::input::TermRead;
5
5
  use termion::raw::{IntoRawMode, RawTerminal};
6
+ use termion::color;
6
7
 
7
8
  pub struct Size {
8
9
  pub width: u16,
@@ -59,4 +60,10 @@ impl Terminal {
59
60
  pub fn clear_current_line() {
60
61
  print!("{}", termion::clear::CurrentLine);
61
62
  }
63
+ pub fn set_bg_color(color: color::Rgb) {
64
+ print!("{}", color::Bg(color));
65
+ }
66
+ pub fn reset_bg_color() {
67
+ print!("{}", color::Bg(color::Reset));
68
+ }
62
69
  }

Note: On some terminals, such as on Mac, the termion colors won’t be displayed properly. For the sake of the tutorial, you could then use termion::style::Invert. See this github issue for details.

See this step on github

We have started by extending our terminal with a few new functions, to set and to reset the background color. We need to reset the colors after we use them, otherwise the rest of the screen will also be rendered in the same color.

We use that functionality in editor to draw a line of empty spaces where our status bar will be. We have also added a function to draw the message bar below the status bar, but we leave it empty for now.

We want to display the file name next. Let’s adjust our Document to have an optional file name, and set it in open. We’re also going to prepare the Terminal to set and reset the foreground color.

src/document.rs CHANGED
@@ -4,17 +4,19 @@ use std::fs;
4
4
  #[derive(Default)]
5
5
  pub struct Document {
6
6
  rows: Vec<Row>,
7
+ file_name: Option<String>,
7
8
  }
8
9
 
9
10
  impl Document {
10
- pub fn open(filename: &str ) -> Result<Self, std::io::Error> {
11
+ pub fn open(filename: &str) -> Result<Self, std::io::Error> {
11
12
  let contents = fs::read_to_string(filename)?;
12
13
  let mut rows = Vec::new();
13
14
  for value in contents.lines() {
14
15
  rows.push(Row::from(value));
15
16
  }
16
- Ok(Self{
17
- rows
17
+ Ok(Self {
18
+ rows,
19
+ file_name: Some(filename.to_string()),
18
20
  })
19
21
  }
20
22
  pub fn row(&self, index: usize) -> Option<&Row> {
src/terminal.rs CHANGED
@@ -1,9 +1,9 @@
1
1
  use crate::Position;
2
2
  use std::io::{self, stdout, Write};
3
+ use termion::color;
3
4
  use termion::event::Key;
4
5
  use termion::input::TermRead;
5
6
  use termion::raw::{IntoRawMode, RawTerminal};
6
- use termion::color;
7
7
 
8
8
  pub struct Size {
9
9
  pub width: u16,
@@ -66,4 +66,10 @@ impl Terminal {
66
66
  pub fn reset_bg_color() {
67
67
  print!("{}", color::Bg(color::Reset));
68
68
  }
69
+ pub fn set_fg_color(color: color::Rgb) {
70
+ print!("{}", color::Fg(color));
71
+ }
72
+ pub fn reset_fg_color() {
73
+ print!("{}", color::Fg(color::Reset));
74
+ }
69
75
  }

See this step on github

Note that we have not used a String as a type for the file name, but an Option, to indicate that we either have a filename or None, in case no file name is set.

Now we’re ready to display some information in the status bar. We’ll display up to 20 characters of the filename, followed by the number of lines in the file. If there is no filename, we’ll display [No Name] instead.

src/document.rs CHANGED
@@ -4,7 +4,7 @@ use std::fs;
4
4
  #[derive(Default)]
5
5
  pub struct Document {
6
6
  rows: Vec<Row>,
7
- file_name: Option<String>,
7
+ pub file_name: Option<String>,
8
8
  }
9
9
 
10
10
  impl Document {
src/editor.rs CHANGED
@@ -5,6 +5,7 @@ use std::env;
5
5
  use termion::color;
6
6
  use termion::event::Key;
7
7
 
8
+ const STATUS_FG_COLOR: color::Rgb = color::Rgb(63, 63, 63);
8
9
  const STATUS_BG_COLOR: color::Rgb = color::Rgb(239, 239, 239);
9
10
  const VERSION: &str = env!("CARGO_PKG_VERSION");
10
11
 
@@ -201,13 +202,26 @@ impl Editor {
201
202
  }
202
203
  }
203
204
  fn draw_status_bar(&self) {
204
- let spaces = " ".repeat(self.terminal.size().width as usize);
205
+ let mut status;
206
+ let width = self.terminal.size().width as usize;
207
+ let mut file_name = "[No Name]".to_string();
208
+ if let Some(name) = &self.document.file_name {
209
+ file_name = name.clone();
210
+ file_name.truncate(20);
211
+ }
212
+ status = format!("{} - {} lines", file_name, self.document.len());
213
+ if width > status.len() {
214
+ status.push_str(&" ".repeat(width - status.len()));
215
+ }
216
+ status.truncate(width);
205
217
  Terminal::set_bg_color(STATUS_BG_COLOR);
206
- println!("{}\r", spaces);
207
- Terminal::reset_bg_color();
218
+ Terminal::set_fg_color(STATUS_FG_COLOR);
219
+ println!("{}\r", status);
220
+ Terminal::reset_fg_color();
221
+ Terminal::reset_bg_color();;
208
222
  }
209
- fn draw_message_bar(&self) {
210
- Terminal::clear_current_line();
223
+ fn draw_message_bar(&self) {
224
+ Terminal::clear_current_line();
211
225
  }
212
226
  }
213
227
 

See this step on github

We make sure to cut the status string short in case it doesn’t fit inside the width of the window. Notice how we still use code that draws spaces up to the end of the screen, so that the entire status bar has a white background.

We are using a new macro here, format!. It’s similar to print! and println!, without actually printing something out to the screen.

Now let’s show the current line number, and align it to the right edge of the screen.

src/editor.rs CHANGED
@@ -210,9 +210,17 @@ impl Editor {
210
210
  file_name.truncate(20);
211
211
  }
212
212
  status = format!("{} - {} lines", file_name, self.document.len());
213
- if width > status.len() {
214
- status.push_str(&" ".repeat(width - status.len()));
213
+
214
+ let line_indicator = format!(
215
+ "{}/{}",
216
+ self.cursor_position.y.saturating_add(1),
217
+ self.document.len()
218
+ );
219
+ let len = status.len() + line_indicator.len();
220
+ if width > len {
221
+ status.push_str(&" ".repeat(width - len));
215
222
  }
223
+ status = format!("{}{}", status, line_indicator);
216
224
  status.truncate(width);
217
225
  Terminal::set_bg_color(STATUS_BG_COLOR);
218
226
  Terminal::set_fg_color(STATUS_FG_COLOR);

See this step on github

The current line is stored in cursor_position.y, which we add 1 to since the position is 0-indexed. We are subtracting the length of the new part of the status bar from the number of spaces we want to produce, and add it to the final formatted string.

Status message

We’re going to add one more line below our status bar. This will be for displaying messages to the user, and prompting the user for input when doing a search, for example. We’ll store the current message in a struct called StatusMessage, which we’ll put in the editor state. We’ll also store a timestamp for the message, so that we can erase it a few seconds after it’s been displayed.

src/editor.rs CHANGED
@@ -2,6 +2,8 @@ use crate::Document;
2
2
  use crate::Row;
3
3
  use crate::Terminal;
4
4
  use std::env;
5
+ use std::time::Duration;
6
+ use std::time::Instant;
5
7
  use termion::color;
6
8
  use termion::event::Key;
7
9
 
@@ -15,12 +17,26 @@ pub struct Position {
15
17
  pub y: usize,
16
18
  }
17
19
 
20
+ struct StatusMessage {
21
+ text: String,
22
+ time: Instant,
23
+ }
24
+ impl StatusMessage {
25
+ fn from(message: String) -> Self {
26
+ Self {
27
+ time: Instant::now(),
28
+ text: message,
29
+ }
30
+ }
31
+ }
32
+
18
33
  pub struct Editor {
19
34
  should_quit: bool,
20
35
  terminal: Terminal,
21
36
  cursor_position: Position,
22
37
  offset: Position,
23
38
  document: Document,
39
+ status_message: StatusMessage,
24
40
  }
25
41
 
26
42
  impl Editor {
@@ -39,9 +55,16 @@ impl Editor {
39
55
  }
40
56
  pub fn default() -> Self {
41
57
  let args: Vec<String> = env::args().collect();
58
+ let mut initial_status = String::from("HELP: Ctrl-Q = quit");
42
59
  let document = if args.len() > 1 {
43
60
  let file_name = &args[1];
44
- Document::open(&file_name).unwrap_or_default()
61
+ let doc = Document::open(&file_name);
62
+ if doc.is_ok() {
63
+ doc.unwrap()
64
+ } else {
65
+ initial_status = format!("ERR: Could not open file: {}", file_name);
66
+ Document::default()
67
+ }
45
68
  } else {
46
69
  Document::default()
47
70
  };
@@ -52,6 +75,7 @@ impl Editor {
52
75
  document,
53
76
  cursor_position: Position::default(),
54
77
  offset: Position::default(),
78
+ status_message: StatusMessage::from(initial_status),
55
79
  }
56
80
  }
57
81
 
@@ -230,6 +254,12 @@ impl Editor {
230
254
  }
231
255
  fn draw_message_bar(&self) {
232
256
  Terminal::clear_current_line();
257
+ let message = &self.status_message;
258
+ if Instant::now() - message.time < Duration::new(5, 0) {
259
+ let mut text = message.text.clone();
260
+ text.truncate(self.terminal.size().width as usize);
261
+ print!("{}", text);
262
+ }
233
263
  }
234
264
  }
235
265
 

See this step on github

We initialize status_message to a help message with the key bindings. We also take the opportunity and set the status message to an error if we can’t open the file, something that we silently ignored before. To do that, we have to rearrange the code to open a document a bit, so that the correct doc is loaded and the correct status message is set.

Now that we have a status message to display, we can modify the draw_message_bar() function.

First we clear the message bar with Terminal::clear_current_line();. We did not need to do that for the status bar, since we are always overwriting the full line on every render. Then we make sure the message will fit the width of the screen, and then display the message, but only if the message is less than 5 seconds old.

This means that we keep the old status message around, even if we are no longer displaying it. That is ok, since that data structure is small anyways and is not designed to grow over time.

When you start up the program now, you should see the help message at the bottom. It will disappear when you press a key after 5 seconds. Remember, we only refresh the screen after each key press.

In the next chapter, we will turn our text viewer into a text editor, allowing the user to insert and delete characters and save their changes to disk.

Conclusion

In this chapter, all the refactoring of the previous chapters has paid off, as we where able to extend our editor effortlessly

I hope that, like in the last chapter, you are looking at your new text viewer with pride. It’s coming along! Let’s focus on editing text in next chapter.