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
:
We’ll now notify the user how much data was written using set_message()
. Will also all Ctrl+S
to our initial message displayed:
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.
Now let’s increment dirty
after each operation that makes a change to the text:
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:
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()
:
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
:
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 getcannot borrow `self.editor_rows` as mutable more than once at a time
. This is becausecurrent_row
refers to a reference to aRow
withinVec<Row>
and we obtained that reference usingcursor_y
. So inserting a new row might change the actual position ofcurrent_row
to a new position which isn’t indexed bycursor_y
. For instance, if after inserting a new row, the current row’s position is rathercursor_y+1
and notcursor_y
,current_row
would then be pointing to anotherRow
reference and that’s not what we want. A workaround would be to reassigncurrent_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
:
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!
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
:
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:
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.