Build Your Text Editor With Rust! Part 4

Kofi Otuo
13 min readOct 8, 2021

--

In the third part of this tutorial, we did some low-level terminal input/output handling, and used that to draw to the screen and allow the user to move the cursor around. Now let’s get our program to display text files.

First we’ll create a struct which holds the contents for each row:

Each row is represented as an element in row_contents. The elements in row_contents would be displayed in order i.e the first element in row_contents would be displayed on the first row and so on. Also note that we’re using Box<str> instead of String since we won’t be modifying the file contents yet (immutable string). For now let’s display a line with saying “Hello World”. We'll also add a function that returns the number of rows in the file along with a get_row() method:

Let’s display it:

We wrap our previous row-drawing code in an if statement that checks whether we are currently drawing a row that is part of the file, or a row that comes after the file.

To draw a row that’s part of the file, we simply add it to editor_contents. But first, we take care to truncate the rendered line if it would go past the end of the screen, similar to what we did to the welcome message (just that we used a shorthand, std::cmp::min, to add a little variety to our code 😃).

Now let’s open and read an actual file:

First, we create a from_file(&Path) method which reads the contents of a file. We then convert the contents into lines. Since the lines() returns &str, we use the .into() method to convert &str to Box<str> and collect the result as Vec<Box<str>>.

Next, we modify our new function to open the file from the argument passed, if any. Also note that we modified get_row to return the corresponding row in the file. number_of_rows now returns the total number of rows (or lines) in the file.

Our code doesn’t compile yet. Let’s modify draw_rows()to show lines as it is in the file:

Displaying Files

Try the program by running cargo run Cargo.lock. Explore with different file paths

Now let’s fix a small bug. We want the welcome message to only display when the user starts the program with no arguments, and not when they open a file, as the welcome message could get in the way of displaying the file. If EditorRows.number_of_rows is 0, it means there was no file opened or it was an empty file, so we will show the welcome message in that case.

Hide Welcome

Vertical 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 a row_offset variable to our CursorController struct, which will keep track of what row of the file the user is currently scrolled to. We initialize it to 0, meaning the file is scrolled to the top by default.

Now let’s have draw_rows() display the correct range of lines of the file according to the value of row_offset.

To get the row of the file that we want to display at each i position, we add row_off to the i position. So we define a new variable file_row that contains that value, and use that as the index in editor_rows.get_row.

Now where do we set the value of row_offset? Our strategy will be to check if the cursor has moved outside of the visible window, and if so, adjust row_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 before we refresh the screen.

First, we check if the cursor is above the visible window, and if so, scroll up to where the cursor is (Note that whenever cursor_y is less than row_offset, it means the cursor is outside the visible window, If it’s still confusing, you can log the values of cursor_y and row_offset into a file and compare them). This can be expanded to:

if self.cursor_y < self.row_offset {
self.row_offset = self.cursor_y
}
// the above can be simplified to (you can use the one you prefer):self.row_offset = cmp::min(self.row_offset, self.cursor_y);

The if statement in scroll() checks if the cursor is past the bottom of the visible window. We do this by checking if the current cursor_y is greater than screen_rows plus row_offset (Note that row_offset refers to what’s at the top of the screen which isn’t visible, and screen_rows refers to what’s currently visible, so as soon as you scroll past the visible window, row_offset becomes 1 and continues to increase as you scroll down).

Let’s call our scroll function so it can take effect:

Now, let’s allow the cursor to advance past the bottom of the screen (but not past the bottom of the file).

And finally:

Vertical Scrolling

If you try to scroll back up, you may notice the cursor isn’t being positioned properly. That is because cursor_y no longer refers to the position of the cursor on the screen. It refers to the position of the cursor within the text file. To position the cursor on the screen, we now have to subtract row_offset from the value of cursor_y.

You should be able to scroll through the file now.

Horizontal Scrolling ↔️

Now let’s work on horizontal scrolling. We’ll implement it in just about the same way we implemented vertical scrolling. Start by adding a column_offset variable and initialize it to 0:

Now we have to find a way to get the range of str that we will display. We will find our starting point, which would be 0 or column_offset, and then the len of str we should display. To get the len of the text to be displayed, we subtract the number of characters that are to the left of the offset from the length of the row. If the len is 0, then the starting point should also be 0. This would produce the following logic (in draw_rows):

Which can be simplified to:

Now let’s update scroll() to handle horizontal scrolling, similar to vertical scrolling:

Finally, we allow the user to scroll past the right edge of the screen:

Now let’s fix the cursor position similar to what we did in the vertical scrolling:

Limit Scrolling to Right ⏯

Currently, both cursor_x and cursor_y refer to the cursor’s position within the file, not their position on the screen. So our goal with the next few steps is to limit the values of cursor_x and cursor_y to only ever point to valid positions in the file. Otherwise, the user could move the cursor way off to the right of a line and start inserting text there, which wouldn’t make much sense. (The only exceptions to this rule are that cursor_x can point one character past the end of a line so that characters can be inserted at the end of the line, and cursor_y can point one line past the end of the file so that new lines at the end of the file can be added easily.)

Let’s start by not allowing the user to scroll past the end of the current line.

First, let’s change our move_cursor function’s signature:

Then:

Limit Scrolling

Since cursor_y is allowed to one past the last line of the file, we have to first check if the cursor is on an actual line in the file. If it is, editor_rows.get_row(at) would return the text on that row. We then check if the cursor_x is to the left of the end of that line before moving the cursor to the right.

Snap Cursor To End Of Line 🤌

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_x 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_x if it ends up past the end of the line it’s on:

Snap

We perform the correction after moving the cursor so that the “snapping” takes places after the cursor is moved to a different line. Note that if the cursor_y is outside the file i.e. on the line past the end of the file, we cursor_x a value of 0.

Moving Left At Start Of Line 🔚

Let’s allow the user to press ← at the beginning of the line to move to the end of the previous line.

We make sure they aren’t on the very first line before we move them up a line.

Moving Right At 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.

Moving Right

We compare the current cursor_x to the current row size. If the cursor_x is less than the row size, we increase the cursor_x so we move the cursor to the next char. However, if the cursor_x is already past the end of the line, we move cursor down the next line, if any.

Rendering Tabs ✳️

If you tried opening a file with a lot of tabs, you’d notice the tab takes up width of about 8 columns. The length of a tab is up to the terminal being used and its settings. We want to know the length of each tab, and we also want control over how to render tabs, so we’re going to add a second field, render, to our new Row struct. It will contain the actual characters to draw on the screen for that row of text. We’ll only use render for tabs for now, but in the future it could be used to render non-printable control characters such as Ctrl-A (this is a common way to display control characters in the terminal).

You may also notice that when the tab character displayed by the terminal doesn’t erase any characters on the screen within that tab. All a tab does is move the cursor forward to the next tab stop, similar to a carriage return or newline. This is another reason why we want to render tabs as multiple spaces, since spaces erase whatever character was there before.

Let’s start by adding Row. It will contain row_content and render. We’ll also modify EditorRows struct to use Row.

Now let’s make a render_row method which fills render with chars from row_content.

First, we calculate the potential capacity of the render string using fold. We have to loop through the chars of the row_content and if the char is a tab, we add 8 else we add 1. After setting the capacity, we then go through the chars to check whether the current character is a tab. If it is, we append one space (because each tab must advance the cursor forward at least one column), and then append spaces until we get to a tab stop, which is a column that is divisible by 8.

Let’s now get our from_file() method working. We’ll also add a get_render and get_editor_row methods:

Now let’s replace get_row with get_render in draw_rows():

Use render

At this point, we should probably make the length of a tab stop a constant, so we can easily modify it.

Tab stop

Tabs And Cursor ✴️

The cursor doesn’t currently interact with tabs very well. When we position the cursor on the screen, we’re still assuming each character takes up only one column on the screen. To fix this, let’s introduce a new horizontal coordinate variable, render_x. While cursor_x is an index into the chars of row_content, the render_x variable will be an index into the render field. If there are no tabs on the current line, then cursor_x will be the same as render_x. If there are tabs, then render_x will be greater than cursor_x by however many extra spaces those tabs take up when rendered.

Let’s create a get_render_x() function. We’ll need to loop through all the characters to the left of cursor_x, and figure out how many spaces each tab takes up:

For each character, if it’s a tab we use render_x % TAB_STOP to find out how many columns we are to the right of the last tab stop, and then subtract that from TAB_STOP - 1 to find out how many columns we are to the left of the next tab stop. We then add 1, which gets us to the next tap stop (Try and use a simple grid to understand the logic. Also note that we’re using .fold(), which automatically accumulates the result for us). I grouped them to make it easier to understand; you can cancel out the 1s when you understand the calculation well. Notice how this works even if we are currently on a tab stop.

Let’s call get_render_x() at the top of scroll() to finally set render_x to its proper value.

We check if cursor_y is within the file before setting render_x.

Let’s now modify various portions of our code to use render_x instead of cursor_x:

Tabs

You can now confirm that the cursor works properly with tabs.

Scrolling With PageUp And PageDown 📜

Let’s now make the Page Up and Page Down keys scroll up or down an entire page.

Page Up, Page Down

To scroll up or down a page, we position the cursor either at the top or bottom of the screen, and then simulate an entire screen’s worth of ↑ or ↓ keypresses. Delegating to move_cursor() takes care of all the bounds-checking and cursor-fixing that needs to be done when moving the cursor. Note that we set cursor_y to number_of_rows if PageDown will position the cursor beyond the file (that’s what cmp::min does in this case).

Move To End Of Line With End Key

Now let’s have the End key move the cursor to the end of the current line. (The Home key already moves the cursor to the beginning of the line, since we made cursor_x relative to the file instead of relative to the screen.)

End Key

The End key brings the cursor to the end of the current line. If there is no current line, then cursor_x must be 0 and it should stay at 0, so there’s nothing to do.

Status Bar ℹ️

We will conclude this part of the tutorial by implementing 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 file-type when we implement syntax highlighting.

First we’ll simply make room for a one-line status bar at the bottom of the screen:

We decrement y, which represents the screen_rows, so that draw_rows() doesn’t try to draw a line of text at the bottom of the screen. We also have draw_rows() print a newline after the last row it draws, since the status bar is now the final line being drawn on the screen.

Notice how with those two changes, our text viewer works just fine, including scrolling and cursor movement, and the last line where our status bar will be is left alone by the rest of the display code.

To make the status bar stand out, we’re going to display it with inverted colors: black text on a white background. We’ll use crossterm::style::Attribute::Reverse to invert the colors.

Let’s draw a blank white status bar of inverted space characters:

In draw_status_bar(), we first add Reverse attribute. All the text that comes after the Reverse attribute would be inverted. After we’re done, we reset the colors back to normal

Since we want to display the filename in the status bar, let’s add a filename string to EditorRows, and save a copy of the filename there when a file is opened.

When the program is ran without arguments, we set the file name to None. We use PathBuf, which is the owned version of Path and is mutable. This can be helpful if you want to modify the name of the file.

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.

Status Bar Left

If you’re not familiar with .and_then, it calls the closure if the Option is Some. So if filename is Some(..), .and_then would call the closure else it would return None.

Now only the filename would show even if the arguments passed into the program was an absolute path. 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 the code that draws spaces up to the end of the screen, so that the entire status bar has a white background.

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

Status Bar Right

The current line is stored in cursor_y, which we add 1 to since cursor_y is 0-indexed. After printing the first status string, we want to keep printing spaces until we get to the point where if we printed the second status string, it would end up against the right edge of the screen. That happens when win.size.0 - i is equal to the length of the second status string (Note that i refers to the length of the status bar currently printed). At that point we print the status string and break out of the loop, as the entire status bar has now been printed.

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 string and also store a timestamp for the message, so that we can erase it a few seconds after it’s been displayed. We’ll use std::time::Instant to work with time

Before we display the message, we want to make sure the message is more than 5 seconds old. If it’s more than 5 seconds old, we would reset message and set_time to None:

Status Message

Let’s add a status_message to the Output struct and set an initial message of HELP: Ctrl-Q = Quit:

Let’s now show the status message. We’ll make room for a second line beneath our status bar where we’ll display the message:

We decrement win_size.0 (which refers to the screen rows) again, and print a newline after the first status bar. We now have a blank final line once again.

Then we add a draw_message_bar function:

Draw Status Message

We clear the message bar before displaying the message, if any. We also make sure to reduce the length of the status message to fit the screen.

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 keypress.

Whew! 🥵

That would be it for this part 😁. We have converted our program to a text viewer, and hopefully you’ve learnt something from this part of the tutorial. In the next part, we will turn our text viewer into a text editor, allowing the user to insert and delete characters and save their changes to disk.

--

--

Kofi Otuo

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