Build Your Text Editor With Rust! Part 5

Kofi Otuo
9 min readOct 11, 2021

--

In the 4th part of this walk-through, we converted our program to a text viewer, allowing us to scroll through text files using keys such as PageUp, PageDown, End, Home, ArrowLeft and ArrowRight. In this part, we would allow the user to edit text and also save the changes to the disk.

Insert Ordinary Characters 🔡

Let’s begin by writing a function that inserts a single character into a row, at a given position:

First we changed the type of row_content to String to make it mutable. Then we modify the function signature of new. Note how this little change doesn’t break the rest of our program 😀. We use String::insert to insert the new letter. If at is equal to the string’s len, the letter would be appended to the string. Finally we call render_row so that render would be updated.

Recall that we allow the user to move the cursor up to 1 line beneath the file. What if the user decides to move the cursor to that line and insert characters over there? We should create an insert_row function which would first add a new empty row to the file contents, then we can insert characters there.

We use the #[derive] procedural macro to implement a Default method for Row. This default trait can be used as a fall back to some kind of default value, a value we don’t particularly care what it is. In this case, the default value creates a new Row instance with both row_content and render being empty strings with no memory allocations.

If you would like a tutorial on #derive macro (what it is about and how to write your own), “clap” on this tutorial. At about 100 claps, I would do a tutorial on #derive. You can also comment a tutorial you’d like to have.

Let’s put our function into effect:

We added a new function get_editor_mut which returns a mutable reference to Row (it’s quite common in Rust to see functions with a _mut counterpart). As explained earlier, first we check if the cursor is on the line beneath the file. If so, we add a new row. After inserting the character, we increase the cursor_x by 1. In process_keypress function, any key which isn’t already mapped to a function would be passed to insert_char. Note that we restrict the modifiers so that something like Ctrl+ p won’t insert p;

We’ve now officially upgraded our text viewer to a text editor. We’re ready to save the new file.

Save To Disk 💾

Now let’s add a save function which would handle all the saving:

First we convert the row_contents, which is a Vec, to a string. We do that by first creating an iterator of the elements of row_contents, then we limit the iterator to only the row_content field of Row and then collect it as a Vec<&str>. Then we use the join() function to create one string consisting of sub-strings joined by \n i.e the new line separator. (You do not have to explicitly specify the type as String as I did. I just specified it for clarity).

When opening the file, we specified write(true) and create(true). create(true) creates a new file if the file doesn’t already exist. write(true) makes the file writable. Usually to write to files, you could simply use std::fs::write(). But that truncates the file completely before writing the contents. By truncating the file ourselves to the same length as the data we are planning to write into it, we are making the whole overwriting operation a little bit safer in case the set_len function succeeds but the write() call fails. In that case, the file would still contain most of the data it had before. But if the file was truncated completely and then the write failed, you’d end up with all of your data lost.

Also note that we don’t have to “close” the file, as in most programming languages. Rust automatically closes the file when the file instance is dropped

All we have to do now is map a key to save(), so let’s do it! We’ll use Ctrl-S:

Ctrl + S

We’ll now notify the user how much data was written using set_message(). Will also all Ctrl+S to our initial message displayed:

Save Status Message

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 text buffer “dirty” if it has been modified since opening or saving the file. Let’s add dirty to Output and initialize it to 0:

Let’s show the state of dirty in the status bar, by displaying (modified) after the filename if the file has been modified.

Dirty Flag

Now let’s increment dirty after each operation that makes a change to the text:

Increase Dirty

We could have used E.dirty = 1 instead of E.dirty++, but by incrementing it we can have a sense of “how dirty” the file is, which could be useful.

Now we want (modified) to disappear when we save the file so let’s simply set dirty to 0 after the save method:

Now you should see (modified) appear in the status bar when you first insert a character, and you should see it disappear when you save the file to disk.

Quit confirmation 🙅‍♂️

Now we’re ready to warn the user about unsaved changes when they try to quit. If dirty is set, 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:

Quit Confirmation

We use quit_times to track the number of times the user pressed Ctrl-Q. Each time they press Ctrl-Q with unsaved changes, we set the status message and decrement quit_times. When quit_times gets to 0, we finally allow the program to exit. When they press any key other than Ctrl-Q, then quit_times gets reset back to 3 at the end of the process_keypress function.

Backspacing 🔙

Let’s implement backspacing next. First we’ll create an delete_char function, which deletes a character in row_content. It would be very similar to insert_char:

Now let’s create a similar function in Output, which does the various checks, similar to how we implemented Output.insert_char:

If the cursor’s past the end of the file, then there is nothing to delete, and we return immediately. Otherwise, we get the Row the cursor is on, and if there is a character to the left of the cursor, we delete it and move the cursor one to the left. We also increase dirty in that case.

Let’s map Backspace and Delete to delete_char():

Backspace

Pressing the → key and then Backspace is equivalent to what you would expect from pressing the Delete key in a text editor: it deletes the character to the right of the cursor. So that is how we implement the Delete key above.

Backspacing At The Start Of The Line ◀️

Currently, delete_char() doesn’t do anything when the cursor is at the beginning of a line. When the user backspaces at the beginning of a line, we want to append the contents of that line to the previous line, and then delete the current line. This therefore backspaces the implicit \n character in between the two lines to join them into one line.

Let’ create a join_adjacent_rows() method to do that:

First, we remove the next row and then we join it to the current row. After that, we call render_row to update the render field of Row.

Now we modify our delete_char function in Output:

Backspace Row

If the cursor is at the beginning of the first line, then there’s nothing to do, so we return immediately. Otherwise, if we find that cursor_x == 0, we call join_adjacent_rows. We also set cursor_x to the end of the contents of the previous row before appending to that row. That way, the cursor will end up at the point where the two lines joined. Also note that we moved dirty so it is increased when a character is deleted or when lines are joined.

Notice that pressing the Delete key at the end of a line works as the user would expect, joining the current line with the next line. This is because moving the cursor to the right at the end of a line moves it to the beginning of the next line. So making the Delete key an alias for the → key followed by the Backspace key still works.

Enter ¶

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, depending on where the cursor is. Let’s modify our insert_row function to able to insert a row at the index specified by the new at argument:

We have to update insert_char:

Now let’s add insert_newline method which would be mapped to the Enter key:

If we’re at the beginning of a line, all we have to do is insert a new blank row before the line we’re on.

Otherwise, we have to split the line we’re on into two rows. We have to truncate the current line the cursor is on to a size equal to cursor_x. We also have to call render_row to update the contents of render. We then insert a new row with contents of the previous line from cursor_x onward.

After adding the new row, we increase cursor_y and set cursor_x as 0 so the cursor moves to the start of the new row. And don’t forget to increase dirty

Note that if you rather try truncating current_row after inserting new row, the program won’t compile. You’d get cannot borrow `self.editor_rows` as mutable more than once at a time. This is because current_row refers to a reference to a Row within Vec<Row> and we obtained that reference using cursor_y. So inserting a new row might change the actual position of current_row to a new position which isn’t indexed by cursor_y. For instance, if after inserting a new row, the current row’s position is rather cursor_y+1 and not cursor_y, current_row would then be pointing to another Row reference and that’s not what we want. A workaround would be to reassign current_row after the insertion as that would return the right reference that we want.

Finally, let’s actually map the Enter key to the insert_newline:

Enter

That concludes all of the text editing operations we are going to implement. If you wish, and if you are brave enough, you may now start using the editor to modify its own code for the rest of the tutorial. If you do, you should probably backup every now and then (using git or similar).

Save As ✉️

Currently, when the user runs our program with no arguments, they get a blank file to edit but have no way of saving. We need a way of prompting the user to input a filename when saving a new file. We want our prompt method to be able to accept something like Save as: {}, and then fill {} with the input received from the user. Fortunately macros can help us do just that!

Prompt Macro

Let’s try to understand what prompt!() does. It takes “2” arguments. The first one is an expression which we have restricted the type of the expression to only Output using the line

let output:&mut Output = &mut $output;

This forces only instances of Output to be passed into the macro.

The second argument is args which is a token tree (tt). The token tree refers to a single token or tokens in matching delimiters (), [], or {}). The tt type enables our macro to take format arguments similar to println!().

This would allow us to use the macro just as we use println!(). The * operator means the tokens can repeat any number of types.

The user’s input is stored in a String. We then enter an infinite loop that repeatedly sets the status message, refreshes the screen, and waits for a keypress to handle. Not that in setting the status message, we pass $($arg)* into the format!() macro. This is because the format!() macro also takes tokens. We pass $($args)* before passing input. This would cause input to be the last format argument. So if something like Save as {} or {} is passed into prompt!(), the user input would be inserted in the last {}.

When the user presses Enter, and their input is not empty, the status message is cleared and their input is returned. Otherwise, when they input a character, we append it to input. We then return None if there was no input or Some(input) is there was some input.

Depending on where you defined the prompt!() macro, you may have to add #[macro_export] or #[macro_use] so that various functions can have access to the macro. If you defined the macro after the function definition, you have to add #[macro_export] or #[macro_use] appropriately.

Now let’s prompt the user for a filename before saving if filename is None:

Save As

Next, let’s allow the user to press Escape to cancel the input prompt:

When the prompt is cancelled, we clear input and then return None. Now let’s check if the prompt was None. If it’s we’d show “Save Aborted”:

Let’s conclude this part by allowing the user to press Backspace or Delete in the input prompt:

Prompt Final

We simply use .pop to remove the last char.

In the next part, we’ll make use of prompt!() to implement an incremental search feature in our editor.

--

--

Kofi Otuo

Sr. Software Engineer in Systems, Mobile and Blockchain programming. I write coding tutorials. Reach Me: https://www.upwork.com/freelancers/~0196d30a485de56f48