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:
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.
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:
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:
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:
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.
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()
:
At this point, we should probably make the length of a tab stop a constant, so we can easily modify it.
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 1
s 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
:
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.
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.)
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.
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.
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
:
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:
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.