Build Your Text Editor With Rust! Part 2

Kofi Otuo
7 min readOct 4, 2021

--

In the first part of this tutorial, we set up our project and created a new Rust binary called pound. So far it only displays “Hello world!” in the terminal. Let us now try and read key presses from the user.

This code contains a lot of information, so let’s go over it. To obtain user input, we need to bring the io (input/output) library into scope. The io library comes from the standard library ( std). std::io::Read is a trait which provides .read method. The stdin function returns an instance of std::io::Stdin, which is a type that represents a handle to the standard input for your terminal. Since we want to read 1 byte at a time, we pass [0;1] to the read function. When read() reaches end of file it returns 0.

To exit the above program, press Ctrl-D to tell read() that it’s reached the end of file. Or you can always press Ctrl-C to signal the process to terminate immediately.

Press q To Quit ⚒

Let’s add a new way to quit the program.

Press q to quit

Now to quit the program, you need to enter text with ‘q’ in it and press Enter. The program would read each letter, store it in the buf variable and then check if that letter is the same as q . Note the [b'q'] . buf is of type [u8;1] and can only be compared with a similar type. The prefix b provides byte literal which is equivalent to u8 .

Instead of buf != [b'q'] you can write buf[0] != b'q' in this case.

Canonical and Raw Mode ⛓

By default, your terminal starts in canonical mode. This means that the keyboard input is sent to your program after you press Enter key. The input is also echoed in the terminal that’s why you can see what you typed. This mode can be helpful since you can edit what you typed, and use keys like delete and backspace to edit before you press Enter so the program reads your input.

What we want is raw mode, which is quite opposite of canonical mode. Fortunately the crossterm crate provides an easy way to get the terminal into raw mode. Recall that in the first part, we added the crossterm dependency.

Since we’re using the crossterm crate, we don’t have to manually modify the terminal attributes ourselves.

Enable Raw Mode 🔓

As already mentioned, the function to enable raw mode is found in crossterm crate:

Now let us try it out. Compile and run the program. You’d realize that the keys you type aren’t showing again. You’d also realize that you don’t have to press q and then Enter to exit the program. Only pressing q exits the program. You may also notice that no input is displaying on the terminal even after the program exits. Let us modify the code to restore the terminal state when the program exits.

Disable Raw Mode 🔐

It may seem adding terminal::disable_raw_mode() to the last line of our code might do the trick, but that would introduce a bug in our code. Let’s try it out.

Now suppose a function after enable_raw_made() throws an error and panics, disable_raw_mode() won’t be called and the terminal would remain in raw mode. Try it by placing panic!("Error occurred") anywhere after terminal::enable_raw_mode() . To fix let’s create struct called CleanUp :

Let’s break it down. We created a new struct CleanUp and implemented Drop for it. Now drop() is called in cases such as when the instance of the struct (in our case, the _clean_up variable) goes out of scope normally (in this case when main() returns) or when there’s a panic while the instance is still in scope. Run the program and observe the difference compared to the one with the bug

The panic!() line can be added anywhere after the terminal::enable::raw_mode() line to observe similar effects

Now we’re sure the terminal returns to it’s original state after our program exits, we can remove the panic!().

You might have also realized that Ctrl+D and Ctrl+C doesn’t end the program again. Only q ends the program. Let’s take a peek as to what’s happening under the hood

Display Keypresses 📟

Modify the code:

.is_control() tests whether a character is a control character. Control characters are non-printable characters that we don’t want to print to the screen. ASCII codes 0–31 are all control characters, and 127 is also a control character. ASCII codes 32–126 are all printable. (Check out the ASCII table to see all of the characters)

Note that we’re using println!() with \r as the last argument. This is because we enabled raw mode. Remove \r like this and notice the difference:

Using println!() without \r you’ll see that the newline characters we’re printing are only moving the cursor down, and not to the left side of the screen. This constantly moves the output to the right. \r moves the cursor back to the left side of the screen.

Now it’s time to try out our program and discover a few things. Run the program and try out keys such as the arrow keys, or Escape, or Page Up, or Page Down, or Home, or End, or Backspace, or Delete, or Enter. Try key combinations with Ctrl, like Ctrl-A, Ctrl-B, etc.

You’ll notice a few interesting things:

  • Arrow keys, Page Up, Page Down, Home, and End all input 3 or 4 bytes to the terminal: 27, '[', and then one or two other characters. This is known as an escape sequence. All escape sequences start with a 27 byte. Pressing Escape sends a single 27 byte as input.
  • Backspace is byte 127. Delete is a 4-byte escape sequence.
  • Enter is either byte 10, which is a newline character, also known as '\n' or byte 13, which is carriage return, also known as \r.
  • Ctrl-A is 1, Ctrl-B is 2, Ctrl-C is… oh, that terminates the program, right. But the Ctrl key combinations that do work seem to map the letters A–Z to the codes 1–26

Now we know how various keypresses translate into the bytes.

Migrating Project To Use crossterm 🦾

It could be difficult keeping all the various keys and their corresponding byte literal in mind. You could keep referring but that can be time consuming. The crossterm crate also provides abstraction over various key events so we don’t have to keep in mind that Arrow Up maps to 27[A and so on.

Let us use those crossterm features:

Migrating project to crossterm

Now that’s a lot. Let us try and wrap our head around it. loop { .. } works similar to while() . We moved our condition from the while function and placed it in the loop block to make it more readable. We’re using crossterm::event::read() instead of the default std::io::read() . This makes it easier to interpret the events from the terminal. event::read() returns an Event , which is an enum. Since we’re only interested in key presses for now, we check if the Event returned is a Key. We then check if the key pressed is q. If the user pressed q, we break out of the loop and the program terminates.

Run the program and type various key combinations. This would help in understanding how the program works.

Timeout for read()

Currently read() will wait indefinitely for input from the keyboard before it returns. What if we want to do something like animate something on the screen while waiting for user input? Or what if we want to end the program after some time of idleness? We can set a timeout, so that read() returns if it doesn’t get any input for a certain amount of time. We will use the crossterm::event::poll function along with read:

Read timeout

event::poll checks whether an Event is available within the specified time given. Play around with the given time. If no Event is available within the given time, poll returns false.

Error handling 🚫

Since we’re done migrating our project to use crossterm , let’s handle potential errors. So far we’ve been using .expect(" .. ") . Having to write .expect() every time could get tiring. Let’s use a shorthand provided by Rust. Let us modify our main() function this way:

Error handling

The ? operator can only be used in a method that returns Result or Option so we have to modify our main() to return a Result. The crossterm::Result<T> can be expanded to std::result::Result<T, std::io::Error>. So for our main() function, the return type can be translated to std::result::Result<(), std::io::Error>. Let’s take the poll function for instance. It returns std::result::Result<Event, std::io::Error> . Since the second argument in Result returned by poll (i.e. std::io::Error) is the same type as the Result returned by main(), we can use the ? operator on poll function within the main() method.

Next Steps 👣

Let’s conclude this part of the tutorial by quickly reviewing what we have done. First, we read keypresses, then we modified the program to exit when q is pressed. We then enabled raw mode (which enabled us to see how various keys such as Home is translated into bytes) and also found a way to properly disable raw mode when our program exits. We also integrated crossterm to our project. Lastly we modified how we handled errors.

In the next part, we’ll do some more low-level terminal input and output handling, and use that to draw to the screen and allow the user to move the cursor around!

Contact me🔎

If you’re facing any issues, you can quickly reach me on Upwork

--

--

Kofi Otuo

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