Building my own Text Editor

If you would like to read about the process of me building my own text editor, including the struggles I had to overcome, keep reading! This blog will walk you through the key steps/parts of the Windows version of my editor, and how I put it all together!


Entering Raw Mode in the Terminal

To make a terminal-based text editor, you need a way for the terminal to accept input without the need for pressing the Enter/Return key. To solve this issue, we put our terminal into what is known as 'Raw Mode'.

Putting the terminal in raw mode is OS-specific, as you must interface directly with your OS API. Since this is a cross-platform project, I had to interface with both the Win32 API and some Unix APIs.

To set the terminal in raw mode on Windows, we first create a variable that grabs the current/default terminal mode. We can do this using Win32's GetConsoleMode function. An example of how to do this would be

Get Default Mode Example
#include <Windows.h>
DWORD defaultMode; //DWORD is just a typedef of unsigned long
GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &defaultMode);

With the default mode captured, we are ready to enable raw mode. To do this, we need a new variable to store all the new raw mode flags, and then call another function from the Win32 API.

Enable Raw Mode Example
DWORD rawMode = ENABLE_EXTENDED_FLAGS | (defaultMode & ~ENABLE_LINE_INPUT & ~ENABLE_PROCESSED_INPUT
  & ~ENABLE_ECHO_INPUT & ~ENABLE_PROCESSED_OUTPUT & ~ENABLE_WRAP_AT_EOL_OUTPUT)
SetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), rawMode);

So, what does any of this mean? Let's start from the beginning. First, we are retrieving the default terminal settings from Windows using GetConsoleMode and storing it into defaultMode. These settings control how text is displayed and entered into the terminal.

Next, using the default mode as a base, we set up a variable to set our own terminal settings. We do this by enabling extended flags, which allows us to enable/disable certain flags. Then, we disable certain flags that control how input is processed by the terminal. We don't want the terminal to process any of the input, since we need to control exactly what each input does.

The Windows API contains macros for each of these flags (highlighted in purple). By disabling each of these flags, we are essentially telling the terminal to send every character to us, without processing it.

We can wrap this all into functions to allow us to easily enable/disable raw input mode, like so:

#include <Windows.h>
DWORD defaultMode; //DWORD is just a typedef of unsigned long
GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &defaultMode);

void enableRawInputMode(){
  DWORD rawMode = ENABLE_EXTENDED_FLAGS | (defaultMode & ~ENABLE_LINE_INPUT & ~ENABLE_PROCESSED_INPUT
    & ~ENABLE_ECHO_INPUT & ~ENABLE_PROCESSED_OUTPUT & ~ENABLE_WRAP_AT_EOL_OUTPUT);
  SetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), rawMode);
}

void disableRawInputMode(){
  SetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), defaultMode);
}

With these functions set up, any time we need to enable or disable raw mode, its as easy as calling the function.


Getting Terminal Size

Next up on the list is to get the terminal size. This is the total size of displayable area within the terminal. This information is important so we know how many rows of data can be displayed at a time, as well as how many characters can be displayed left-right without the lines wrapping.

We can achieve this pretty simply with the Win32 API, setting it up in a function like:

int rows, cols;
void getWindowSize(){
  CONSOLE_SCREEN_BUFFER_INFO screenInfo;
  GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &screenInfo));
  rows = screenInfo.srWindow.Bottom - screenInfo.srWindow.Top + 1;
  cols = screenInfo.srWindow.Right - screenInfo.srWindow.Left + 1;
}

We now have access to the total number of displayable rows and columns. Make sure the rows and columns are stored somewhere accessible to the part of your program responsible for displaying the file to the user, as you will need them to know how much information you can display.

That's all we need from the Win32 API for displaying and getting input from the user without them needing to press Enter/Return. Next up is controlling what the input does.


Handling Input

The Win32 API provides a very handy function, _getch, which helps parse the input from the user quickly. This function returns an int, which represents a character code. Knowing this, we can do the following:

Intro to _getch()
#include <conio.h>
int input = _getch();

Without providing any extra functionality other than allowing the user to insert characters, we are basically done. There will be some weird things that happen if the user presses a key that isn't just a standard character (letter or number), but it works!

However, let's take it one step further and prevent these weird bugs. There are 2 special return codes from  _getch that we need to focus on: 0 and 224.

0 denotes the use of a function key (F1, F2, F3, etc.), while 224 denotes the use of an action key (arrow keys, home, end, delete, page up/down, and their Ctrl variants).

To fix this, we can read that input and either ignore it or process it farther to get the specific key pressed. Putting this all into a function, we get something like:

#include <conio.h>
const int functionKeyCode = 0;
const int actionKeyCode = 224;
int handleInput(){
  int input = _getch();
  if(input == functionKeyCode){
    int _ = _getch(); //Ignore this input
  }
  else if(input == actionKeyCode){
    input = _getch(); //Get the specific action key pressed
    //How you handle the action keys goes here
    return input;
  }
  return input;
}

For the action keys to work properly, you must do something different with how they act. In my case, I chose to use an enum of action types, and depending on what keycode was returned after the action keycode, I returned a specific action type.


Displaying Text to User

Displaying the text to the user is pretty much the same as displaying text on your console through normal means. With C++23 you can use std::print, otherwise you can use std::cout

For example, to display "Hello, World", you can do something like:

Displaying Text to user Example
#include <iostream>
int main(){
  std::cout << "Hello, World" << std::endl;
}

Basically, the only thing that's different is how you control what part of the file should be displayed to the user.

There are several ways you can choose to store the text. I chose to go with std::vector<std::string>

To keep track of which rows within the vector I should display, I store a row offset. Rather than starting from the beginning of the vector when displaying to the user, I start from this row offset. For example, say we have this scenario:

Row Offset Example
//fileRows is defined somewhere in the file and contains all the rows of text
std::string buffer = "";
for(int i = rowOffset; i < rows + rowOffset; ++i){
  buffer.append(fileRows[i]);
  buffer.append("\r\n"); //New lines should print on new rows
}
std::cout << buffer;
std::cout.flush(); //cout.flush forces the buffer to be printed to the console

Remember how I said you would need to keep the rows and columns for the terminal stored somewhere? This is where that becomes relevant.

If the strings for each row are less than the max columns of the terminal, then this will work fine. But what if the lines are longer? Well, just like how we store the row offset, we can store a column offset, and do:

Column Offset Example
std::string buffer = "";
for(int i = rowOffset; i < rows + rowOffset; ++i){
  std::string renderedLine = fileRows[i];
  renderedLine = renderedLine.substr(colOffset, colOffset + cols);
  buffer.append(renderedLine);
  buffer.append("\r\n"); //New lines should print on new rows
}
std::cout << buffer;
std::cout.flush(); //cout.flush forces the buffer to be printed to the console

If you want just a plain text editor, without syntax highlighting or anything special, this pretty much sums up rendering to the user. We have to handle tabs slightly differently to get them to render properly as the column offset moves, but that will come later. For now, we can render text to the user just fine.

In order for text to constantly update on screen, we need a way to clear individual rows or the entire terminal on each update. There isn't many good ways to achieve this that are completely cross-compatible. However, almost all current terminals support ANSI Escape codes.

You can read more about ANSI Escape Codes here, but for now we will stick to some basic ones.

Escape Code "\x1b[2J" will clear the entire terminal, or Escape Code "\x1b[0K" will clear the line from the cursor position to the end of the row. To prevent the screen from flickering on each update, I chose to use the latter.

We also need a way to control where the text is being written to. This is controlled by the terminal's cursor position. We can also use ANSI Escape Codes to move the cursor. So, to start from the beginning, we need to move the cursor to (1, 1), the top-left corner. This can be achieved with Escape Code "\x1b[1;1H".

The console will also store previous 'frames', allowing the user to scroll up and view them. We can clear this with Escape Code "\x1b[3J". So, let's put this all together:

std::string buffer = "\x1b[1;1H"; //Move cursor to (1,1)
for(int i = rowOffset; i < rows + rowOffset; ++i){
  std::string renderedLine = fileRows[i];
  renderedLine = renderedLine.substr(colOffset, colOffset + cols);
  buffer.append(renderedLine);
  buffer.append("\x1b[0K\r\n"); //Clear everything after last character in line in this row
}
buffer.append("\x1b[3J"); //Clear saved lines
std::cout << buffer;
std::cout.flush(); //cout.flush forces the buffer to be printed to the console

And just like that, you are displaying text to the user while making sure only the current data is being displayed. Now let's move on to controlling the cursor so we can control where we type.


Controlling the File Cursor

We need to constantly know where in the file we are currently at. We can do this by storing two variables: cursorX and cursorY. As the user inputs text, the cursorX position needs to be moved to the right, and if they delete characters with Backspace, it needs to move to the left. If they use the Arrow Keys, both cursors may need to move.

Handling character insertion is the easiest scenario. We can just do the following:

void insertChar(const char c){
  std::string& currentLine = fileRows.at(cursorY);
  currentLine.insert(currentLine.begin() + cursorX, c);
  ++cursorX;
}

This will insert a character at the current cursor position and move the cursor one position to the right.

Handling Backspace is mostly similar, but there are a couple of extra things to keep in mind: If we are at the beginning of the file, and if we are at the beginning of a row.

If we are at the beginning of the file, we don't want to do anything, since there is nothing behind the cursor. If we are at the beginning of a row, we want to delete the row and move everything on it to the previous line.

We can achieve that with the following:

void deleteChar(){
  std::string& currentLine = fileRows.at(cursorY);
  if(cursorX == 0){
    if(cursorY == 0) return; //Don't do anything if at beginning of file
    else{
      std::string& previousLine = fileRows.at(cursorY - 1);
      previousLine.append(currentLine);
      fileRows.erase(fileRows.begin() + cursorY);
      --cursorY;
    }
  }
  else{
    currentLine.erase(currentLine.begin(), cursorX - 1);
    --cursorX;
  }
}

Arrow key functionality is pretty simple, just moving cursorX and cursorY depending on which arrow key was pressed. It requires similar bounds checking to make sure you don't go past the begining/end of the line/file, but otherwise pretty straight-forward.

Also, notice how we were using references of the lines this time? References are named aliases to whatever they are referencing, meaning they 'are' the item they are referencing, just a different name. We can use references to pass items around to functions without creating copies, much like pointers, without the need for the arrow-operator. We can also use references like this to make it easier to access a specific element of a vector so we don't have to keep doing std::vector::at(index).

With cursor movement complete, we can move on to how we load and save the file.


File Handling

Later on, we will want to allow the user to enter a file name, but for now we will just use a test value. To read file contents we need an input file stream, and to save them we need an output file stream. It is also a good idea to build a file path, so we can use filesystem for that. We can load contents pretty simply like this:

Reading From File Example
#include <fstream>
#include <sstream>
#include <filesystem>
const std::string testFile = "test.cpp";
std::filesystem::path path = std::filesystem::current_path() / testFile;
std::ifstream file(path);
std::stringstream ss;
ss << file.rdbuf();

The file contents are now stored into a stringstream, which we can then scan through the string and find where all the new lines (\r\n) are at. There are a couple of ways to do this, but we will stick to this method for now:

Separating File into Rows
std::vector<std::string> fileRows;
std::string& fileStr = ss.str();
size_t filePos = 0;
while(filePos < fileStr.length()){
  if(fileStr.at(filePos) == "\r\n"){
    fileRows.emplace_back(fileStr.substr(0, filePos);
    fileStr.erase(fileStr.begin(), fileStr.begin() + filePos + 2);
    filePos = 0;
  }
  else{
    ++filePos;
  }
}
fileRows.emplace_back(fileStr); //Last line to add

This is where we introduce fileRows. Each new line gets loaded into its own string so we can display it to the user. We remove the \r\n from the string since we want full control over the display.

Saving contents to the file is pretty simple as well. But we have to add the new line characters back into the final string.

Saving to the File
std::string output;
for (size_t i = 0; i < fileRows.size(); ++i){
  if(i == fileRows.size() - 1){
    output.append(fileRows.at(i));
  }
  else{
    output.append(fileRows.at(i) + "\r\n");
  }
}

std::ofstream outFile(path);
outFile << output;

Finally, we can wrap both of these into their own functions so we can load and save the file with ease.

#include <fstream>
#include <sstream>
#include <filesystem>

std::vector<std::string> fileRows;
const std::string testFile = "test.cpp";
std::filesystem::path path = std::filesystem::current_path() / testFile;

void loadFromFile(){
  std::ifstream file(path);
  std::stringstream ss;
  ss << file.rdbuf();

  std::string& fileStr = ss.str();
  size_t filePos = 0;
  while(filePos < fileStr.length()){
    if(fileStr.at(filePos) == '\r\n'){
      fileRows.emplace_back(fileStr.substr(0, filePos);
      fileStr.erase(fileStr.begin(), fileStr.begin() + filePos + 2);
      filePos = 0;
    }
    else{
      ++filePos;
    }
  }
  fileRows.emplace_back(fileStr); //Last line to add
}

void saveToFile(){
  std::string output;
  for (size_t i = 0; i < fileRows.size(); ++i){
    if(i == fileRows.size() - 1){
      output.append(fileRows.at(i));
    }
    else{
      output.append(fileRows.at(i) + "\r\n");
    }
  }

  std::ofstream outFile(path);
  outFile << output;
}

Handling Tabs

The last major hurdle is how to handle tab characters without breaking the display. I chose to replace tab characters with spaces, with each tab stop occuring at a multiple of 8 spaces. You can handle it like so:

int maxSpacesForTab = 7, tabSpacing = 8;
void replaceTabs(std::string& renderedLine){
  size_t length = renderedLine.length();
  for(size_t i = 0; i < length; ++i){
    if(renderedLine[i] != '\t') continue; //If its not a tab, skip over it
    renderedLine[i] = ' '; //Replace tab with space
    int t = maxSpacesForTab - (i % tabSpacing);
    while(t > 0){
      renderedLine.insert(renderedLine.begin() + i, ' ');
      --t;
      ++i;
    }
    if(renderedLine.length() > lineLength) lineLength = renderedLine.length();
  }
}

What we do here is we calculate the distance from the current character to the nearest multiple of 8, and add spaces until we reach that multiple of 8.

If you choose to implement this, you also have to make sure you update your cursor position or properly as well.


Closing Notes

And with that, all the major pieces of the text editor are here! Some of the implementation details have been left out, so if you following along with this while building your own editor, feel free to check out my GitHub repository linked above if you get stuck.

This has been a fun learning experience for me to work on building and optimizing. And now maybe you can learn too!