Hecto, Chapter 5: A text editor

Hecto, Chapter 5: A text editor

Previous chapter - Overview - Appendices - Next Chapter

Now that hecto can read files, let’s see if we can teach it to edit files as well.

Insert ordinary characters

Let’s begin by writing a function that inserts a single character into a Document, at a given position. We start by allowing to add a character into our string at a given position.

src/document.rs CHANGED
@@ -1,3 +1,4 @@
1
+ use crate::Position;
1
2
  use crate::Row;
2
3
  use std::fs;
3
4
 
@@ -28,4 +29,14 @@ impl Document {
28
29
  pub fn len(&self) -> usize {
29
30
  self.rows.len()
30
31
  }
32
+ pub fn insert(&mut self, at: &Position, c: char) {
33
+ if at.y == self.len() {
34
+ let mut row = Row::default();
35
+ row.insert(0, c);
36
+ self.rows.push(row);
37
+ } else if at.y < self.len() {
38
+ let row = self.rows.get_mut(at.y).unwrap();
39
+ row.insert(at.x, c);
40
+ }
41
+ }
31
42
  }
src/editor.rs CHANGED
@@ -101,6 +101,7 @@ impl Editor {
101
101
  let pressed_key = Terminal::read_key()?;
102
102
  match pressed_key {
103
103
  Key::Ctrl('q') => self.should_quit = true,
104
+ Key::Char(c) => self.document.insert(&self.cursor_position, c),
104
105
  Key::Up
105
106
  | Key::Down
106
107
  | Key::Left
src/row.rs CHANGED
@@ -1,6 +1,7 @@
1
1
  use std::cmp;
2
2
  use unicode_segmentation::UnicodeSegmentation;
3
3
 
4
+ #[derive(Default)]
4
5
  pub struct Row {
5
6
  string: String,
6
7
  len: usize,
@@ -44,4 +45,16 @@ impl Row {
44
45
  fn update_len(&mut self) {
45
46
  self.len = self.string[..].graphemes(true).count();
46
47
  }
48
+ pub fn insert(&mut self, at: usize, c: char) {
49
+ if at >= self.len() {
50
+ self.string.push(c);
51
+ } else {
52
+ let mut result: String = self.string[..].graphemes(true).take(at).collect();
53
+ let remainder: String = self.string[..].graphemes(true).skip(at).collect();
54
+ result.push(c);
55
+ result.push_str(&remainder);
56
+ self.string = result;
57
+ }
58
+ self.update_len();
59
+ }
47
60
  }

See this step on github

Let’s focus first on the changes to Row.

Here, we handle two cases: If we happen to be at the end of our string, we push the character onto it. This can happen if the user is at the end of a line and keeps typing. If not, we are rebuilding our string by going through it character by character.

We use the iterator’s take and skip functions to create new iterators, one that goes from 0 to at (including at), and another one that goes from the element after at to the end. We use collect to combine these iterators into strings. collect is very powerful and can collect into different collections. Since there are multiple collections collect can create, we have to provide the type of result and remainder, otherwise Rust wouldn’t know what kind of collection to create.

We’re now also deriving default for Row. We’ll use that in Document.

Similar to what we did in Row, we are handling the case where the user is attempting to insert at the bottom of our document, for which case we create a new row.

Now we need to call that method when a character is entered. We do that by extending process_keypress in editor.

With that change, we can now add characters anywhere in the document. But our cursor does not move - so we are essentially typing in our text backwards. Let’s fix that now by treating “Enter a character” as “Enter a character and go to the right”.

src/editor.rs CHANGED
@@ -101,7 +101,10 @@ impl Editor {
101
101
  let pressed_key = Terminal::read_key()?;
102
102
  match pressed_key {
103
103
  Key::Ctrl('q') => self.should_quit = true,
104
- Key::Char(c) => self.document.insert(&self.cursor_position, c),
104
+ Key::Char(c) => {
105
+ self.document.insert(&self.cursor_position, c);
106
+ self.move_cursor(Key::Right);
107
+ }
105
108
  Key::Up
106
109
  | Key::Down
107
110
  | Key::Left

See this step on github

You should now be able to confirm that putting in characters works, even at the bottom of the file.

Simple deletion

We now want Backspace and Delete to work.

Let’s start with Delete, which should remove the character under the cursor. If your cursor is a line, |, instead of a block, “under the cursor” means “in front of the cursor”, since the cursor is a blinking line displayed on the left side of its position. Let’s start by adding a delete function on a row.

src/document.rs CHANGED
@@ -39,4 +39,11 @@ impl Document {
39
39
  row.insert(at.x, c);
40
40
  }
41
41
  }
42
+ pub fn delete(&mut self, at: &Position) {
43
+ if at.y >= self.len() {
44
+ return;
45
+ }
46
+ let row = self.rows.get_mut(at.y).unwrap();
47
+ row.delete(at.x);
48
+ }
42
49
  }
src/editor.rs CHANGED
@@ -105,6 +105,7 @@ impl Editor {
105
105
  self.document.insert(&self.cursor_position, c);
106
106
  self.move_cursor(Key::Right);
107
107
  }
108
+ Key::Delete => self.document.delete(&self.cursor_position),
108
109
  Key::Up
109
110
  | Key::Down
110
111
  | Key::Left
src/row.rs CHANGED
@@ -57,4 +57,15 @@ impl Row {
57
57
  }
58
58
  self.update_len();
59
59
  }
60
+ pub fn delete(&mut self, at: usize) {
61
+ if at >= self.len() {
62
+ return;
63
+ } else {
64
+ let mut result: String = self.string[..].graphemes(true).take(at).collect();
65
+ let remainder: String = self.string[..].graphemes(true).skip(at + 1).collect();
66
+ result.push_str(&remainder);
67
+ self.string = result;
68
+ }
69
+ self.update_len();
70
+ }
60
71
  }

See this step on github

As you can see, the code is very similar to the insert code we wrote before. The difference is that in Row, we are not adding a character, but we are skipping the one we want to delete when glueing together result and remainder. And in Document, we do not need to handle the case where we want to delete a row (yet), which makes that code a bit simpler than the symmertrical insert code.

You should now be able to delete characters from with in a line. Let’s tackle Backspace next: Essentially, Backspace is a combination of going left and deleting, so we adjust process_keypress as follows:

src/editor.rs CHANGED
@@ -106,6 +106,12 @@ impl Editor {
106
106
  self.move_cursor(Key::Right);
107
107
  }
108
108
  Key::Delete => self.document.delete(&self.cursor_position),
109
+ Key::Backspace => {
110
+ if self.cursor_position.x > 0 || self.cursor_position.y > 0 {
111
+ self.move_cursor(Key::Left);
112
+ self.document.delete(&self.cursor_position);
113
+ }
114
+ }
109
115
  Key::Up
110
116
  | Key::Down
111
117
  | Key::Left

See this step on github

Backspace now works within a line. We even make sure that if we are at the beginning of the document, we are not doing a delete - otherwise, we would start removing elements behind the cursor.

If you do a backspace at the beginning of a line, however, it moves up a line without doing anything else. Let’s fix that in the next sections.

Complex deletion

There are two edge cases which we can’t handle right now, and that is either using Backspace at the beginning of a line, or using Delete at the end of a line. In our case, Backspace simply goes to the left, which, at the beginning of the line, means to go to the end of the previous line, and then attempts to delete a character. This means that the Backspace case will be solved as soon as we allow a delete at the end of a line.

src/document.rs CHANGED
@@ -40,10 +40,17 @@ impl Document {
40
40
  }
41
41
  }
42
42
  pub fn delete(&mut self, at: &Position) {
43
- if at.y >= self.len() {
43
+ let len = self.len();
44
+ if at.y >= len {
44
45
  return;
45
46
  }
46
- let row = self.rows.get_mut(at.y).unwrap();
47
- row.delete(at.x);
47
+ if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y < len - 1 {
48
+ let next_row = self.rows.remove(at.y + 1);
49
+ let row = self.rows.get_mut(at.y).unwrap();
50
+ row.append(&next_row);
51
+ } else {
52
+ let row = self.rows.get_mut(at.y).unwrap();
53
+ row.delete(at.x);
54
+ }
48
55
  }
49
56
  }
src/row.rs CHANGED
@@ -68,4 +68,8 @@ impl Row {
68
68
  }
69
69
  self.update_len();
70
70
  }
71
+ pub fn append(&mut self, new: &Self) {
72
+ self.string = format!("{}{}", self.string, new.string);
73
+ self.update_len();
74
+ }
71
75
  }

See this step on github

We start by giving Row the ability to append another row to it. We use this ability in Document. Now, the code in Document looks a bit complex, and I’m going to explain in a second why this is the case. What it does, though, is checking if we are at the end of a line, and if a line follows after this one. If that’s the case, we remove the next line from our vec and append it to the current row. If that’s not the case, we simply try to delete from the current row.

Now, why does the code look so complicated? Can’t we just move the definition of row up above the if statement to make things clearer?

This is our second big encounter with Rust’s borrow checker. We can’t have two mutable references to elements within the vector at the same time, and we can’t mutate the vector while we have a mutable reference to an element in it. Why? Because let’s say we have a vector with A, B and C in it, and we have a reference to B. A reference is like a pointer which points to where B resides in the memory. Now we remove A, which causes B and C to move to the left. Our reference would suddenly no longer point to B, but to C!

This means that we can’t have a reference to row and then delete part of the vector. So we first read row’s length directly without retaining a reference. Then we mutate the vector by removing an element from it, and then we create our mutable reference to row.

Try rewriting the code if you want to, and check what the compiler tells you in case you are interested.

The Enter key

The last editor operation we have to implement is the Enter key. The Enter key allows the user to insert new lines into the text, or split a line into two lines. You can actually add newlines that way right now, but as you might expect, the handling is less than optimal. This is because the newlines are inserted as part of the row instead of resulting in the creation of a new row.

Let’s start with the easy case, adding a new row below the current one.

src/document.rs CHANGED
@@ -29,7 +29,22 @@ impl Document {
29
29
  pub fn len(&self) -> usize {
30
30
  self.rows.len()
31
31
  }
32
+ fn insert_newline(&mut self, at: &Position) {
33
+ if at.y > self.len() {
34
+ return;
35
+ }
36
+ let new_row = Row::default();
37
+ if at.y == self.len() || at.y.saturating_add(1) == self.len() {
38
+ self.rows.push(new_row);
39
+ } else {
40
+ self.rows.insert(at.y + 1, new_row)
41
+ }
42
+ }
32
43
  pub fn insert(&mut self, at: &Position, c: char) {
44
+ if c == '\n' {
45
+ self.insert_newline(at);
46
+ return;
47
+ }
33
48
  if at.y == self.len() {
34
49
  let mut row = Row::default();
35
50
  row.insert(0, c);

See this step on github

We call insert_newline from insert in case a newline is handed to us. In insert_newline, we are checking if enter was pressed either on the last row of the document, or one row below it (remember that we allow navigating there). If that is the case, we push a new row at the end of our vec. If that’s not the case, we insert a new row at the correct position.

Let’s now handle the case where we are in the middle of a row.

src/document.rs CHANGED
@@ -33,12 +33,12 @@ impl Document {
33
33
  if at.y > self.len() {
34
34
  return;
35
35
  }
36
- let new_row = Row::default();
37
- if at.y == self.len() || at.y.saturating_add(1) == self.len() {
38
- self.rows.push(new_row);
36
+ if at.y == self.len() {
37
+ self.rows.push(Row::default());
38
+ return;
39
- } else {
40
- self.rows.insert(at.y + 1, new_row)
41
39
  }
40
+ let new_row = self.rows.get_mut(at.y).unwrap().split(at.x);
41
+ self.rows.insert(at.y + 1, new_row);
42
42
  }
43
43
  pub fn insert(&mut self, at: &Position, c: char) {
44
44
  if c == '\n' {
src/row.rs CHANGED
@@ -72,4 +72,11 @@ impl Row {
72
72
  self.string = format!("{}{}", self.string, new.string);
73
73
  self.update_len();
74
74
  }
75
+ pub fn split(&mut self, at: usize) -> Self {
76
+ let beginning: String = self.string[..].graphemes(true).take(at).collect();
77
+ let remainder: String = self.string[..].graphemes(true).skip(at).collect();
78
+ self.string = beginning;
79
+ self.update_len();
80
+ Self::from(&remainder[..])
81
+ }
75
82
  }

See this step on github

We have now added a method called split, which truncates the current row up until a given index, and returns another row with everything behind that index. On Document, we are now only pushing an empty row if we are below the last line, in any other case, we are changing the current row with split and insert the result. This works even at the end of a line, in which case the new row would simply contain an empty string.

Great! Now we can move around our document, add whitespaces, characters, even emojis, remove lines and so on. But editing is obviously useless without saving, so let’s handle that next.

Save to disk

Now that we’ve finally made text editable, let’s implement saving to disk. We start with implementing a save method in Document:

src/document.rs CHANGED
@@ -1,6 +1,7 @@
1
1
  use crate::Position;
2
2
  use crate::Row;
3
3
  use std::fs;
4
+ use std::io::{Error, Write};
4
5
 
5
6
  #[derive(Default)]
6
7
  pub struct Document {
@@ -68,4 +69,14 @@ impl Document {
68
69
  row.delete(at.x);
69
70
  }
70
71
  }
72
+ pub fn save(&self) -> Result<(), Error> {
73
+ if let Some(file_name) = &self.file_name {
74
+ let mut file = fs::File::create(file_name)?;
75
+ for row in &self.rows {
76
+ file.write_all(row.as_bytes())?;
77
+ file.write_all(b"\n")?;
78
+ }
79
+ }
80
+ Ok(())
81
+ }
71
82
  }
src/editor.rs CHANGED
@@ -55,7 +55,7 @@ impl Editor {
55
55
  }
56
56
  pub fn default() -> Self {
57
57
  let args: Vec<String> = env::args().collect();
58
- let mut initial_status = String::from("HELP: Ctrl-Q = quit");
58
+ let mut initial_status = String::from("HELP: Ctrl-S = save | Ctrl-Q = quit");
59
59
  let document = if args.len() > 1 {
60
60
  let file_name = &args[1];
61
61
  let doc = Document::open(&file_name);
@@ -101,6 +101,14 @@ impl Editor {
101
101
  let pressed_key = Terminal::read_key()?;
102
102
  match pressed_key {
103
103
  Key::Ctrl('q') => self.should_quit = true,
104
+ Key::Ctrl('s') => {
105
+ if self.document.save().is_ok() {
106
+ self.status_message =
107
+ StatusMessage::from("File saved successfully.".to_string());
108
+ } else {
109
+ self.status_message = StatusMessage::from("Error writing file!".to_string());
110
+ }
111
+ }
104
112
  Key::Char(c) => {
105
113
  self.document.insert(&self.cursor_position, c);
106
114
  self.move_cursor(Key::Right);
src/row.rs CHANGED
@@ -79,4 +79,7 @@ impl Row {
79
79
  self.update_len();
80
80
  Self::from(&remainder[..])
81
81
  }
82
+ pub fn as_bytes(&self) -> &[u8] {
83
+ self.string.as_bytes()
84
+ }
82
85
  }

See this step on github

We extend Row with a method that allows us to convert the row into a byte array. In Document, write_all takes that byte array and writes it to disk. Since our rows do not contain the newline symbol, we write it out separately. The b in front of the newline string indicates that this is a byte array and not a string.

Since writing may cause errors, our save function returns a Result, and we are using the ? again to pass any errors that might occur to the caller.

In Document, we connect save to Ctrl-S. We check if the write was successful by using is_ok, which returns true in case a Result is Ok and not an Err, and we set the status message accordingly.

Last but not least, we change the initial status to tell our user how to write a file.

Great, now we can open, adjust and save our files!

Save as…

Currently, when the user runs hecto with no arguments, they get a blank file to edit but have no way of saving. Let’s make a prompt() function that displays a prompt in the status bar, and lets the user input a line of text after the prompt:

src/editor.rs CHANGED
@@ -102,6 +102,9 @@ impl Editor {
102
102
  match pressed_key {
103
103
  Key::Ctrl('q') => self.should_quit = true,
104
104
  Key::Ctrl('s') => {
105
+ if self.document.file_name.is_none() {
106
+ self.document.file_name = Some(self.prompt("Save as: ")?);
107
+ }
105
108
  if self.document.save().is_ok() {
106
109
  self.status_message =
107
110
  StatusMessage::from("File saved successfully.".to_string());
@@ -280,6 +283,23 @@ impl Editor {
280
283
  print!("{}", text);
281
284
  }
282
285
  }
286
+ fn prompt(&mut self, prompt: &str) -> Result<String, std::io::Error> {
287
+ let mut result = String::new();
288
+ loop {
289
+ self.status_message = StatusMessage::from(format!("{}{}", prompt, result));
290
+ self.refresh_screen()?;
291
+ if let Key::Char(c) = Terminal::read_key()? {
292
+ if c == '\n' {
293
+ self.status_message = StatusMessage::from(String::new());
294
+ break;
295
+ }
296
+ if !c.is_control() {
297
+ result.push(c);
298
+ }
299
+ }
300
+ }
301
+ Ok(result)
302
+ }
283
303
  }
284
304
 
285
305
  fn die(e: std::io::Error) {

See this step on github

The user’s input is stored in result, which we initialize as an empty string. We enter an infinite loop that repeatadly sets the status message, refreshes the screen, and waits for a key press to handle. When the user presses enter, the status message is cleared and the message is returned. The errors which might occur on the way are propagated up.

Now that the user can save the file, let’s handle a few more cases in our prompt. Let’s now allow the user to cancel and backspace, and let’s also treat an empty input as cancelling.

src/editor.rs CHANGED
@@ -97,21 +97,27 @@ impl Editor {
97
97
  Terminal::cursor_show();
98
98
  Terminal::flush()
99
99
  }
100
+ fn save(&mut self) {
101
+ if self.document.file_name.is_none() {
102
+ let new_name = self.prompt("Save as: ").unwrap_or(None);
103
+ if new_name.is_none() {
104
+ self.status_message = StatusMessage::from("Save aborted.".to_string());
105
+ return;
106
+ }
107
+ self.document.file_name = new_name;
108
+ }
109
+
110
+ if self.document.save().is_ok() {
111
+ self.status_message = StatusMessage::from("File saved successfully.".to_string());
112
+ } else {
113
+ self.status_message = StatusMessage::from("Error writing file!".to_string());
114
+ }
115
+ }
100
116
  fn process_keypress(&mut self) -> Result<(), std::io::Error> {
101
117
  let pressed_key = Terminal::read_key()?;
102
118
  match pressed_key {
103
119
  Key::Ctrl('q') => self.should_quit = true,
104
- Key::Ctrl('s') => {
120
+ Key::Ctrl('s') => self.save(),
105
- if self.document.file_name.is_none() {
106
- self.document.file_name = Some(self.prompt("Save as: ")?);
107
- }
108
- if self.document.save().is_ok() {
109
- self.status_message =
110
- StatusMessage::from("File saved successfully.".to_string());
111
- } else {
112
- self.status_message = StatusMessage::from("Error writing file!".to_string());
113
- }
114
- }
115
121
  Key::Char(c) => {
116
122
  self.document.insert(&self.cursor_position, c);
117
123
  self.move_cursor(Key::Right);
@@ -283,22 +289,35 @@ impl Editor {
283
289
  print!("{}", text);
284
290
  }
285
291
  }
286
- fn prompt(&mut self, prompt: &str) -> Result<String, std::io::Error> {
292
+ fn prompt(&mut self, prompt: &str) -> Result<Option<String>, std::io::Error> {
287
293
  let mut result = String::new();
288
294
  loop {
289
295
  self.status_message = StatusMessage::from(format!("{}{}", prompt, result));
290
296
  self.refresh_screen()?;
291
- if let Key::Char(c) = Terminal::read_key()? {
292
- if c == '\n' {
293
- self.status_message = StatusMessage::from(String::new());
294
- break;
297
+ match Terminal::read_key()? {
298
+ Key::Backspace => {
299
+ if !result.is_empty() {
300
+ result.truncate(result.len() - 1);
301
+ }
295
302
  }
296
- if !c.is_control() {
297
- result.push(c);
303
+ Key::Char('\n') => break,
304
+ Key::Char(c) => {
305
+ if !c.is_control() {
306
+ result.push(c);
307
+ }
308
+ }
309
+ Key::Esc => {
310
+ result.truncate(0);
311
+ break;
298
312
  }
313
+ _ => (),
299
314
  }
300
315
  }
301
- Ok(result)
316
+ self.status_message = StatusMessage::from(String::new());
317
+ if result.is_empty() {
318
+ return Ok(None);
319
+ }
320
+ Ok(Some(result))
302
321
  }
303
322
  }
304
323
 

See this step on github

We have changed a couple of things here, let’s go through them one by one.

prompt no longer only contains a Result, but also an Option. The idea is that if the prompt is successful, it can still return None to indicate that the user has aborted the prompt. We have traded our if let for a match to also handle the cases of backspace and Esc: In the case of Esc, we reset all previously entered text before breaking the loop. In case of Backspace, we reduce the input by one, removing the last character in the progress.

Then, we have created a function save outside of process_keypress. In it, we are now aborting the save operation if the prompt has returned None, but also if the prompt has returned an error.

Dirty flag

We’d like to keep track of whether the text loaded in our editor differs from what’s in the file. Then we can warn the user that they might lose unsaved changes when they try to quit.

We call a Document “dirty” if it has been modified since opening or saving the file. Let’s add a dirty variable to the Document and initialize it with false. We don’t want this to be modified from the outside, so we add a read-only is_dirty function to Document. We’re also setting it to true on any text change, and to false on save.

src/document.rs CHANGED
@@ -7,6 +7,7 @@ use std::io::{Error, Write};
7
7
  pub struct Document {
8
8
  rows: Vec<Row>,
9
9
  pub file_name: Option<String>,
10
+ dirty: bool,
10
11
  }
11
12
 
12
13
  impl Document {
@@ -19,6 +20,7 @@ impl Document {
19
20
  Ok(Self {
20
21
  rows,
21
22
  file_name: Some(filename.to_string()),
23
+ dirty: false,
22
24
  })
23
25
  }
24
26
  pub fn row(&self, index: usize) -> Option<&Row> {
@@ -31,9 +33,6 @@ impl Document {
31
33
  self.rows.len()
32
34
  }
33
35
  fn insert_newline(&mut self, at: &Position) {
34
- if at.y > self.len() {
35
- return;
36
- }
37
36
  if at.y == self.len() {
38
37
  self.rows.push(Row::default());
39
38
  return;
@@ -42,6 +41,10 @@ impl Document {
42
41
  self.rows.insert(at.y + 1, new_row);
43
42
  }
44
43
  pub fn insert(&mut self, at: &Position, c: char) {
44
+ if at.y > self.len() {
45
+ return;
46
+ }
47
+ self.dirty = true;
45
48
  if c == '\n' {
46
49
  self.insert_newline(at);
47
50
  return;
@@ -50,7 +53,7 @@ impl Document {
50
53
  let mut row = Row::default();
51
54
  row.insert(0, c);
52
55
  self.rows.push(row);
53
- } else if at.y < self.len() {
56
+ } else {
54
57
  let row = self.rows.get_mut(at.y).unwrap();
55
58
  row.insert(at.x, c);
56
59
  }
@@ -60,6 +63,7 @@ impl Document {
60
63
  if at.y >= len {
61
64
  return;
62
65
  }
66
+ self.dirty = true;
63
67
  if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y < len - 1 {
64
68
  let next_row = self.rows.remove(at.y + 1);
65
69
  let row = self.rows.get_mut(at.y).unwrap();
@@ -69,14 +73,18 @@ impl Document {
69
73
  row.delete(at.x);
70
74
  }
71
75
  }
72
- pub fn save(&self) -> Result<(), Error> {
76
+ pub fn save(&mut self) -> Result<(), Error> {
73
77
  if let Some(file_name) = &self.file_name {
74
78
  let mut file = fs::File::create(file_name)?;
75
79
  for row in &self.rows {
76
80
  file.write_all(row.as_bytes())?;
77
81
  file.write_all(b"\n")?;
78
82
  }
83
+ self.dirty = false;
79
84
  }
80
85
  Ok(())
81
86
  }
87
+ pub fn is_dirty(&self) -> bool {
88
+ self.dirty
89
+ }
82
90
  }
src/editor.rs CHANGED
@@ -256,12 +256,23 @@ impl Editor {
256
256
  fn draw_status_bar(&self) {
257
257
  let mut status;
258
258
  let width = self.terminal.size().width as usize;
259
+ let modified_indicator = if self.document.is_dirty() {
260
+ " (modified)"
261
+ } else {
262
+ ""
263
+ };
264
+
259
265
  let mut file_name = "[No Name]".to_string();
260
266
  if let Some(name) = &self.document.file_name {
261
267
  file_name = name.clone();
262
268
  file_name.truncate(20);
263
269
  }
264
- status = format!("{} - {} lines", file_name, self.document.len());
270
+ status = format!(
271
+ "{} - {} lines{}",
272
+ file_name,
273
+ self.document.len(),
274
+ modified_indicator
275
+ );
265
276
 
266
277
  let line_indicator = format!(
267
278
  "{}/{}",

See this step on github

Perhaps the only surprising change is that we rearranged the bounds handling in insert a bit. It’s now similar to the checking in delete.

Quit confirmation

Now we’re ready to warn the user about unsaved changes when they try to quit. If document.is_dirty() is true, we will display a warning in the status bar, and require the user to press Ctrl-Q three more times in order to quit without saving.

src/editor.rs CHANGED
@@ -10,6 +10,7 @@ use termion::event::Key;
10
10
  const STATUS_FG_COLOR: color::Rgb = color::Rgb(63, 63, 63);
11
11
  const STATUS_BG_COLOR: color::Rgb = color::Rgb(239, 239, 239);
12
12
  const VERSION: &str = env!("CARGO_PKG_VERSION");
13
+ const QUIT_TIMES: u8 = 3;
13
14
 
14
15
  #[derive(Default)]
15
16
  pub struct Position {
@@ -37,6 +38,7 @@ pub struct Editor {
37
38
  offset: Position,
38
39
  document: Document,
39
40
  status_message: StatusMessage,
41
+ quit_times: u8,
40
42
  }
41
43
 
42
44
  impl Editor {
@@ -76,6 +78,7 @@ impl Editor {
76
78
  cursor_position: Position::default(),
77
79
  offset: Position::default(),
78
80
  status_message: StatusMessage::from(initial_status),
81
+ quit_times: QUIT_TIMES,
79
82
  }
80
83
  }
81
84
 
@@ -116,7 +119,17 @@ impl Editor {
116
119
  fn process_keypress(&mut self) -> Result<(), std::io::Error> {
117
120
  let pressed_key = Terminal::read_key()?;
118
121
  match pressed_key {
119
- Key::Ctrl('q') => self.should_quit = true,
122
+ Key::Ctrl('q') => {
123
+ if self.quit_times > 0 && self.document.is_dirty() {
124
+ self.status_message = StatusMessage::from(format!(
125
+ "WARNING! File has unsaved changes. Press Ctrl-Q {} more times to quit.",
126
+ self.quit_times
127
+ ));;
128
+ self.quit_times -= 1;
129
+ return Ok(());
130
+ }
131
+ self.should_quit = true
132
+ }
120
133
  Key::Ctrl('s') => self.save(),
121
134
  Key::Char(c) => {
122
135
  self.document.insert(&self.cursor_position, c);
@@ -140,6 +153,10 @@ impl Editor {
140
153
  _ => (),
141
154
  }
142
155
  self.scroll();
156
+ if self.quit_times < QUIT_TIMES {
157
+ self.quit_times = QUIT_TIMES;
158
+ self.status_message = StatusMessage::from(String::new());
159
+ }
143
160
  Ok(())
144
161
  }
145
162
  fn scroll(&mut self) {

See this step on github

We have added a new constant for the additional times we require the user to press Ctrl-Q, and we use it as an additional field in Editor. When the document is dirty and the user attempts to quit, we count down quit_times until it reaches 0 - then we finally quit. Note that we are returning within the match arm for quit. That way, the code after the match is only called if the user pressed another key than Ctrl-Q, so we can check after the match if quit_times has been modified and reset it if necessary.

Final touches

Congratulations, you have built a text editor! But before we move on and add more functionality in it, let’s check if we really covered our bases. Earlier in this tutorial, we cared about overflows and saturated_adds and so on, but are we really prepared to handle larger files, or will hecto panic? Also, since Rust is all about performance, does our code perform well enough?

First, let’s teach Clippy a few new tricks.

src/main.rs CHANGED
@@ -1,4 +1,4 @@
1
- #![warn(clippy::all, clippy::pedantic)]
1
+ #![warn(clippy::all, clippy::pedantic, clippy::restriction)]
2
2
  mod document;
3
3
  mod editor;
4
4
  mod row;

See this step on github

clippy::restriction contains a lot of warnings that might or might not indicate errors in your code. As you can see when you run cargo clippy now, the results are overwhelming!

Lucky for us, each entry comes with a link and with that link come some explanations. Let’s disable a few items for hecto:

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

See this step on github

If you are interested in what these options are, check their descriptions in the original output of clippy.

The results of Clippy are now much more manageable. There are still many entries regarding integer arithmetic. Before we get to them, let’s ask ourselves: Do we really want to fix all of them? My opinion is: Yes, for two reasons. One is that some of our code relies on implicit contracts behind some functions: We rely other portions of our code to do the checking for us so that we don’t have to. But what if in the future the other part of the code changes?

Another consideration is that if you come across code like a+1;, you have to stop and investigate the surrounding code to check if this operation is valid. You have no indication whether or not the author of this code (which could simply be your Past Self) has paid attention to the potential overflow or not! The easiest thing to do is to disable clippy at this line. Even this lazy solution is an indicator for everyone reviewing the code later that you did, in fact, take overflows into account and made a conscious decision on how to deal with it.

Anyways, let’s jump right in!

Making Clippy happy

src/document.rs CHANGED
@@ -33,11 +33,15 @@ impl Document {
33
33
  self.rows.len()
34
34
  }
35
35
  fn insert_newline(&mut self, at: &Position) {
36
+ if at.y > self.len() {
37
+ return;
38
+ }
36
39
  if at.y == self.len() {
37
40
  self.rows.push(Row::default());
38
41
  return;
39
42
  }
40
43
  let new_row = self.rows.get_mut(at.y).unwrap().split(at.x);
44
+ #[allow(clippy::integer_arithmetic)]
41
45
  self.rows.insert(at.y + 1, new_row);
42
46
  }
43
47
  pub fn insert(&mut self, at: &Position, c: char) {
@@ -58,13 +62,14 @@ impl Document {
58
62
  row.insert(at.x, c);
59
63
  }
60
64
  }
65
+ #[allow(clippy::integer_arithmetic)]
61
66
  pub fn delete(&mut self, at: &Position) {
62
67
  let len = self.len();
63
68
  if at.y >= len {
64
69
  return;
65
70
  }
66
71
  self.dirty = true;
67
- if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y < len - 1 {
72
+ if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y + 1< len {
68
73
  let next_row = self.rows.remove(at.y + 1);
69
74
  let row = self.rows.get_mut(at.y).unwrap();
70
75
  row.append(&next_row);
src/editor.rs CHANGED
@@ -213,14 +213,14 @@ impl Editor {
213
213
  }
214
214
  Key::PageUp => {
215
215
  y = if y > terminal_height {
216
- y - terminal_height
216
+ y.saturating_sub(terminal_height)
217
217
  } else {
218
218
  0
219
219
  }
220
220
  }
221
221
  Key::PageDown => {
222
222
  y = if y.saturating_add(terminal_height) < height {
223
- y + terminal_height as usize
223
+ y.saturating_add(terminal_height)
224
224
  } else {
225
225
  height
226
226
  }
@@ -253,7 +253,7 @@ impl Editor {
253
253
  pub fn draw_row(&self, row: &Row) {
254
254
  let width = self.terminal.size().width as usize;
255
255
  let start = self.offset.x;
256
- let end = self.offset.x + width;
256
+ let end = self.offset.x.saturating_add(width);
257
257
  let row = row.render(start, end);
258
258
  println!("{}\r", row)
259
259
  }
@@ -261,7 +261,7 @@ impl Editor {
261
261
  let height = self.terminal.size().height;
262
262
  for terminal_row in 0..height {
263
263
  Terminal::clear_current_line();
264
- if let Some(row) = self.document.row(terminal_row as usize + self.offset.y) {
264
+ if let Some(row) = self.document.row(self.offset.y.saturating_add(terminal_row as usize)) {
265
265
  self.draw_row(row);
266
266
  } else if self.document.is_empty() && terminal_row == height / 3 {
267
267
  self.draw_welcome_message();
@@ -296,10 +296,9 @@ impl Editor {
296
296
  self.cursor_position.y.saturating_add(1),
297
297
  self.document.len()
298
298
  );
299
+ #[allow(clippy::integer_arithmetic)]
299
300
  let len = status.len() + line_indicator.len();
300
- if width > len {
301
+ status.push_str(&" ".repeat(width.saturating_sub(len)));
301
- status.push_str(&" ".repeat(width - len));
302
- }
303
302
  status = format!("{}{}", status, line_indicator);
304
303
  status.truncate(width);
305
304
  Terminal::set_bg_color(STATUS_BG_COLOR);
@@ -323,11 +322,7 @@ impl Editor {
323
322
  self.status_message = StatusMessage::from(format!("{}{}", prompt, result));
324
323
  self.refresh_screen()?;
325
324
  match Terminal::read_key()? {
326
- Key::Backspace => {
325
+ Key::Backspace => result.truncate(result.len().saturating_sub(1)),
327
- if !result.is_empty() {
328
- result.truncate(result.len() - 1);
329
- }
330
- }
331
326
  Key::Char('\n') => break,
332
327
  Key::Char(c) => {
333
328
  if !c.is_control() {
src/row.rs CHANGED
@@ -23,6 +23,7 @@ impl Row {
23
23
  let end = cmp::min(end, self.string.len());
24
24
  let start = cmp::min(start, end);
25
25
  let mut result = String::new();
26
+ #[allow(clippy::integer_arithmetic)]
26
27
  for grapheme in self.string[..]
27
28
  .graphemes(true)
28
29
  .skip(start)
@@ -57,6 +58,7 @@ impl Row {
57
58
  }
58
59
  self.update_len();
59
60
  }
61
+ #[allow(clippy::integer_arithmetic)]
60
62
  pub fn delete(&mut self, at: usize) {
61
63
  if at >= self.len() {
62
64
  return;

See this step on github

As you can see, these are mainly small changes. I want to point out a few things:

  • We have found several potential bugs or head scratchers. For instance, after one of our last changes, insert_newline did not do some bounds checking on its own. From looking at insert_newline alone it would not be possible to understand that we removed it because right now, insert_newline is only called from insert, where we are already doing bounds checking. This means that there was an implicit contract to whoever calls insert_newline to make sure at.y is not exceeding the current document’s length. We have now corrected this.
  • We have replaced a subtraction from len with an addition to at.y in another place. Why? Because we can easily see in that function that y will always be smaller than len, so there is always room for a +1. It’s not as easily visible that, or if, len will always be greater than 0.
  • While using saturating_sub, we were able to get rid of some size comparisons, which simplifies our code.

Clippy still gives us some warnings, this time about integer division. The problem is the following: If you divide, for example, 100/3, the result will be 33, and the remainder will be removed. That is OK in our case, but the reason to tackle this anyways is the same as before: Anybody reviewing our code can’t be sure whether or not we thought about this, or simply forgot. The least we can do is either leave a comment or a clippy directive, which is essentially the same as a comment saying “Trust me, this stuff is working”.

src/editor.rs CHANGED
@@ -244,6 +244,7 @@ impl Editor {
244
244
  let mut welcome_message = format!("Hecto editor -- version {}", VERSION);
245
245
  let width = self.terminal.size().width as usize;
246
246
  let len = welcome_message.len();
247
+ #[allow(clippy::integer_arithmetic, clippy::integer_division)]
247
248
  let padding = width.saturating_sub(len) / 2;
248
249
  let spaces = " ".repeat(padding.saturating_sub(1));
249
250
  welcome_message = format!("~{}{}", spaces, welcome_message);
@@ -257,11 +258,15 @@ impl Editor {
257
258
  let row = row.render(start, end);
258
259
  println!("{}\r", row)
259
260
  }
261
+ #[allow(clippy::integer_division, clippy::integer_arithmetic)]
260
262
  fn draw_rows(&self) {
261
263
  let height = self.terminal.size().height;
262
264
  for terminal_row in 0..height {
263
265
  Terminal::clear_current_line();
264
- if let Some(row) = self.document.row(self.offset.y.saturating_add(terminal_row as usize)) {
266
+ if let Some(row) = self
267
+ .document
268
+ .row(self.offset.y.saturating_add(terminal_row as usize))
269
+ {
265
270
  self.draw_row(row);
266
271
  } else if self.document.is_empty() && terminal_row == height / 3 {
267
272
  self.draw_welcome_message();

See this step on github

As you can see, we opted for the “Leave a comment” solution here. We also re-indented some code, as the lines tend to get longer now. Let’s tackle clippy’s next grievances now.

src/document.rs CHANGED
@@ -33,19 +33,20 @@ impl Document {
33
33
  self.rows.len()
34
34
  }
35
35
  fn insert_newline(&mut self, at: &Position) {
36
- if at.y > self.len() {
36
+ if at.y > self.rows.len() {
37
37
  return;
38
38
  }
39
- if at.y == self.len() {
39
+ if at.y == self.rows.len() {
40
40
  self.rows.push(Row::default());
41
41
  return;
42
42
  }
43
- let new_row = self.rows.get_mut(at.y).unwrap().split(at.x);
43
+ #[allow(clippy::indexing_slicing)]
44
+ let new_row = self.rows[at.y].split(at.x);
44
45
  #[allow(clippy::integer_arithmetic)]
45
46
  self.rows.insert(at.y + 1, new_row);
46
47
  }
47
48
  pub fn insert(&mut self, at: &Position, c: char) {
48
- if at.y > self.len() {
49
+ if at.y > self.rows.len() {
49
50
  return;
50
51
  }
51
52
  self.dirty = true;
@@ -53,28 +54,29 @@ impl Document {
53
54
  self.insert_newline(at);
54
55
  return;
55
56
  }
56
- if at.y == self.len() {
57
+ if at.y == self.rows.len() {
57
58
  let mut row = Row::default();
58
59
  row.insert(0, c);
59
60
  self.rows.push(row);
60
61
  } else {
61
- let row = self.rows.get_mut(at.y).unwrap();
62
+ #[allow(clippy::indexing_slicing)]
63
+ let row = &mut self.rows[at.y];
62
64
  row.insert(at.x, c);
63
65
  }
64
66
  }
65
- #[allow(clippy::integer_arithmetic)]
67
+ #[allow(clippy::integer_arithmetic, clippy::indexing_slicing)]
66
68
  pub fn delete(&mut self, at: &Position) {
67
- let len = self.len();
69
+ let len = self.rows.len();
68
70
  if at.y >= len {
69
71
  return;
70
72
  }
71
73
  self.dirty = true;
72
- if at.x == self.rows.get_mut(at.y).unwrap().len() && at.y + 1< len {
74
+ if at.x == self.rows[at.y].len() && at.y + 1 < len {
73
75
  let next_row = self.rows.remove(at.y + 1);
74
- let row = self.rows.get_mut(at.y).unwrap();
76
+ let row = &mut self.rows[at.y];
75
77
  row.append(&next_row);
76
78
  } else {
77
- let row = self.rows.get_mut(at.y).unwrap();
79
+ let row = &mut self.rows[at.y];
78
80
  row.delete(at.x);
79
81
  }
80
82
  }

See this step on github

This change is mainly related to accessing rows at certain positions. We used to use the safe method get_mut, which does not panic, even if there was nothing to retrieve, e.g. because the index was wrong. And we called unwrap() directly on it, negating the benefits of using get_mut in the first place. We have replaced it now with a direct access to self.rows. We have left a clippy statement everywhere we did this, to indicate that we did, in fact, check that we’re only accessing valid indices at that time.

If you wanted to make your code even more robust, you could replace these occurrences with proper handling in case an index is out of bounds.

We also changed another thing: There was another implicit contract thing going on, and that was that the length of a document always corresponds to the number of rows in it, so we were calling self.len() instead of self.rows.len() everywhere. Should we ever decide that documents can be longer, all our operations on self.row would fail.

This is not a super important change, but it fits the spirit of our current refactorings. All right, two more clippy warnings to go.

src/editor.rs CHANGED
@@ -58,11 +58,11 @@ impl Editor {
58
58
  pub fn default() -> Self {
59
59
  let args: Vec<String> = env::args().collect();
60
60
  let mut initial_status = String::from("HELP: Ctrl-S = save | Ctrl-Q = quit");
61
- let document = if args.len() > 1 {
62
- let file_name = &args[1];
63
- let doc = Document::open(&file_name);
64
- if doc.is_ok() {
65
- doc.unwrap()
61
+
62
+ let document = if let Some(file_name) = args.get(1) {
63
+ let doc = Document::open(file_name);
64
+ if let Ok(doc) = doc {
65
+ doc
66
66
  } else {
67
67
  initial_status = format!("ERR: Could not open file: {}", file_name);
68
68
  Document::default()

See this step on github

We’re taking advantage of the fact that get, as discussed above, only returns a value to us if it is there, eliminating the need to perform a check before indexing. Then, we remove is_ok in favor of an if_let, to save an unrwap. Finally, we have convinced both clippy and ourselves that our code is good!

Now, there are a few things that Clippy can’t detect, and we should not rely on Clippy alone for our development. We’ll deal with them next.

Performance improvements

So far, our editor does not do much. But there are already some performance improvements that we could make! Performance tweaks are a difficult topic, as it’s hard to draw the line between readable, maintainable code and super-optimized code that is hard to read and maintain. We don’t want hecto to be the fastest editor out there, but it makes sense to take a look at a few performance aspects.

I want us to look out for unnecessary iterations over rows when going through the document from top to bottom, as well as unnecessary iterations over characters as we go through a row left to right. That’s all that we are going to focus on now - no additional caching, no fancy tricks, just looking for redundant operations.

Let’s focus on how we deal with rows. We have a common pattern that we are repeating multiple times. For instance, here’s insert:

pub fn insert(&mut self, at: usize, c: char) {
        if at >= self.len() {
            self.string.push(c);
        } else {
            let mut result: String = self.string[..].graphemes(true).take(at).collect();
            let remainder: String = self.string[..].graphemes(true).skip(at).collect();
            result.push(c);
            result.push_str(&remainder);
            self.string = result;
        }
        self.update_len();
    }

In this implementation, we iterate over our string three times:

  • Once from start to at to calculate result
  • A second time from start to end (ignoring everything between start and at) to calculate remainder
  • and once through the whole string to update len.

That’s not great. Let’s change this.

src/row.rs CHANGED
@@ -9,12 +9,10 @@ pub struct Row {
9
9
 
10
10
  impl From<&str> for Row {
11
11
  fn from(slice: &str) -> Self {
12
- let mut row = Self {
12
+ Self {
13
13
  string: String::from(slice),
14
- len: 0,
15
- };
14
+ len: slice.graphemes(true).count(),
15
+ }
16
- row.update_len();
17
- row
18
16
  }
19
17
  }
20
18
 
@@ -43,43 +41,65 @@ impl Row {
43
41
  pub fn is_empty(&self) -> bool {
44
42
  self.len == 0
45
43
  }
46
- fn update_len(&mut self) {
47
- self.len = self.string[..].graphemes(true).count();
48
- }
49
44
  pub fn insert(&mut self, at: usize, c: char) {
50
45
  if at >= self.len() {
51
46
  self.string.push(c);
52
- } else {
53
- let mut result: String = self.string[..].graphemes(true).take(at).collect();
47
+ self.len += 1;
48
+ return;
54
- let remainder: String = self.string[..].graphemes(true).skip(at).collect();
55
- result.push(c);
56
- result.push_str(&remainder);
57
- self.string = result;
58
49
  }
59
- self.update_len();
50
+ let mut result: String = String::new();
51
+ let mut length = 0;
52
+ for (index, grapheme) in self.string[..].graphemes(true).enumerate() {
53
+ length += 1;
54
+ if index == at {
55
+ length += 1;
56
+ result.push(c);
57
+ }
58
+ result.push_str(grapheme);
59
+ }
60
+ self.len = length;
61
+ self.string = result;
60
62
  }
61
- #[allow(clippy::integer_arithmetic)]
62
63
  pub fn delete(&mut self, at: usize) {
63
64
  if at >= self.len() {
64
65
  return;
65
- } else {
66
- let mut result: String = self.string[..].graphemes(true).take(at).collect();
67
- let remainder: String = self.string[..].graphemes(true).skip(at + 1).collect();
68
- result.push_str(&remainder);
69
- self.string = result;
70
66
  }
71
- self.update_len();
67
+ let mut result: String = String::new();
68
+ let mut length = 0;
69
+ for (index, grapheme) in self.string[..].graphemes(true).enumerate() {
70
+ if index != at {
71
+ length += 1;
72
+ result.push_str(grapheme);
73
+ }
74
+ }
75
+ self.len = length;
76
+ self.string = result;
72
77
  }
73
78
  pub fn append(&mut self, new: &Self) {
74
79
  self.string = format!("{}{}", self.string, new.string);
75
- self.update_len();
80
+ self.len += new.len;
76
81
  }
77
82
  pub fn split(&mut self, at: usize) -> Self {
78
- let beginning: String = self.string[..].graphemes(true).take(at).collect();
79
- let remainder: String = self.string[..].graphemes(true).skip(at).collect();
80
- self.string = beginning;
81
- self.update_len();
82
- Self::from(&remainder[..])
83
+ let mut row: String = String::new();
84
+ let mut length = 0;
85
+ let mut splitted_row: String = String::new();
86
+ let mut splitted_length = 0;
87
+ for (index, grapheme) in self.string[..].graphemes(true).enumerate() {
88
+ if index < at {
89
+ length += 1;
90
+ row.push_str(grapheme);
91
+ } else {
92
+ splitted_length += 1;
93
+ splitted_row.push_str(grapheme);
94
+ }
95
+ }
96
+
97
+ self.string = row;
98
+ self.len = length;
99
+ Self {
100
+ string: splitted_row,
101
+ len: splitted_length,
102
+ }
83
103
  }
84
104
  pub fn as_bytes(&self) -> &[u8] {
85
105
  self.string.as_bytes()

See this step on github

We did two things here:

  • We got rid of update_len, as we are now manually calculating the length on every row operation.
  • We are looping over enumerate, which does not only provide us with the next element, but also with it’s index in the iterator. That way, we can easily calculate the length while we are moving through the row.

Final considerations

While we undoubtedly have made hecto better with these changes, let’s put things a bit into perspective: How likely is it that we will ever see an overflow happening on usize? Well, that depends on your system. You can check the actual size of usize with the following snippet:

fn main() {
    dbg!(std::usize::MAX);        
}

On my machine, it outputs the following:

[src/main.rs:2] std::usize::MAX = 18446744073709551615

That means, we are near an overflow as soon as our doc is close to 18,446,744,073,709,551,615 rows, or if a row has close to 18,446,744,073,709,551,615 characters long.

That is an insanely large number. If every row contained a byte of information, that would be 18 EB of data. EB stands for Exabyte. Good luck finding a hard drive that can handle these data! And even if you could, hecto would run into other issues while handling this insane amount of data.

This does not mean that our work was not important. On the contrary, I believe that thinking about these kinds of things should become a habit while you are coding. However, you should not over-optimize your code for an edge case that will never happen.

Conclusion

You have now successfully built a text editor. If you are brave, you can use hecto to work on hecto. In the next chapter, we will make use of prompt() to implement an incremental search feature in our editor.