Writing a Terminal Text Editor

I wrote Clunk because I was tired of exiting the terminal to take notes. A majority of the work involved was creating the text buffer. I feel there is too much extra overhead code in Clunk's source to properly illustrate some of the base concepts involved. Because of this, I wanted to write a post that would have helped me when I was first approaching the project.

To not muddle the examples, there will be no error or bounds checking. This is not good. Always remember to program defensively. A full gist of this code is available here.


The Buffer

I always appreciate arrays because I feel like they are easily grasped. Most of us have written code that writes to a file:

char *lines[10] = //some data

for( int i = 0; i < 10; i++ ) {
    fprintf( fp, lines[i] );
}

With ncurses, we can print using mvprintw(y, x, text). We can easily modify the example above with this information:

char *lines[10] = //some data

for( int i = 0; i < 10; i++ ) {
    mvprintw( i, 0, lines[i] );
}

One downside of using arrays is their fixed-sized and memory layout. Both inserting in the middle (and therefore shifting all the following elements) and creating a larger array (and copying all the data over) have large execution costs. Even dynamic languages, where these mechanisms are handled behind-the-scenes, suffer from these penalties. Obviously this is not an ideal structure for data that is going to be shifted around a lot.

A very simple way to structure the data in a text editor is to imagine each line of text as a node. These nodes are then linked in a list. So something like:

Hi,
This is some text
 
And this is the end

Will become:

┌─────┬──┐      
│ Hi        ────┐
└─────┴──┘   │ 
┌────────────────────┬──┐
│ This is some text                  ────┐
└────────────────────┴──┘   │
   ┌────────────────────────┘
┌─────┬──┐
│           ────┐
└─────┴──┘   |
┌────────────────────┬──┐
│ And this is the end                  |
└────────────────────┴──┘

This is commonly known as a linked-list and is a easy way to implement a dynamic list without the pitfalls of an array.

Each way will have their own way to define or use linked-list, but in C, a basic implementation looks like:

typedef struct node
{
    char text[128];
    struct node *next;
} node_t;

This is a mirroring of our example. Each node contains a character array and a pointer to the next node. With this, our text "buffer" can be represented by storing the first element in this list and the current element being worked on:

typedef struct
{
    node_t *head;
    node_t *current;
} Buffer;

ncurses

There are lots of ncurses' tutorials out there, so I will not repeat what smarter men have said. Instead, this is just boilerplate code:

#include <ncurses.h>

int main(int argc, char **argv)
{
    int ch = 0;

    initscr();  // initialize the screen for curses
    noecho();   // don't repeat keypresses
    cbreak();   // immediately pass input to our program
    keypad(stdscr, TRUE);   // enable keypad and function keys

    do
    {
        erase();    // clear the screen

        // handle input
        // print text
    } while ((ch = getch()) != KEY_F(10));  // exit on f10

    refresh();  // refresh the previous terminal
    endwin();   // clean up ncurses

    return 0;
}

To compile, we need to include -lncurses in our build process:

all:
	gcc main.c -Wall -lncurses

A Single Line

With all the pieces in place, we can implement a simple proof-of-concept that will allow us to type up to 128 characters on a single line. First, we have a function to see if there is currently a head node, if not, create it, and then place the passed character into the text member.

#include <stdlib.h>
#include <ctype.h>

void buffer_insert_character(Buffer *b, int ch, int x, int y) 
{
    if(b->head == NULL)
    {
        b->head = calloc(1, sizeof(node_t));
        b->current = b->head;
    }

    b->head->text[x] = ch;
}

Next, we will modify the main loop. We will now check to see if the character is printable - if it is, pass it to be appended and increase the current x position. After that has been handled, we point at the head of the linked-list and iterate through every element, printing its text member to the screen.

    int x = 0;
    int y = 0;

    Buffer b = { 0 };

    do
    {
        erase();    // clear the screen

        // handle input
        if( isprint(ch) )
        {
            buffer_insert_character(&b, ch, x, y);
            x++;            
        }

        // print text
        node_t *iter = b.head;
        while( iter != NULL ) 
        {
            mvprintw(0, 0, iter->text);
            iter = iter->next;
        } 
    } while ((ch = getch()) != KEY_F(10));  // exit on f10

Text editors are not very good if you cannot move your cursor around. The input side of this is easy to implement. We introduce a switch, monitor for keys, and adjust our x and y members:

        // handle input
        switch(ch)
        {
            // make sure to add checks for the edges of the screen
            case KEY_RIGHT:
                x++;
                break;
            case KEY_LEFT:
                x--;
                break;
            case KEY_UP:
                y--;
                break;
            case KEY_DOWN:
                y++;
                break;
            default:
                if( isprint(ch) )
                {
                    buffer_insert_character(&b, ch, x, y);
                    x++;            
                }
                break;
        }

To reiterate the comments, make sure you apply bounds checking when implementing this. Your scroll method will determine your checks. I would advise against using macros like I did for Clunk.

To have the cursor show up, we need to move to it after we finish printing all the text:

// print cursor
move(y, x); 

Text Entry with Navigation

Our previously implementation of inserting characters is now broken with the addition of navigation. First we will fix insert on the first line after going right a few characters. In most languages, this will be caused by the string being null-terminated before getting to the next set of text. This can be fixed by putting a space for every null character before the new text:

void buffer_insert_character(Buffer *b, int ch, int x, int y) 
{
    if(b->head == NULL)
    {
        b->head = calloc(1, sizeof(node_t));
        b->current = b->head;
    }
    
    // ensure there are no null characters before our input
    for( int i = 0; i < x; i++ )
    {
        if( b->current->text[i] == 0 )
            b->current->text[i] = ' ';
    }

    b->current->text[x] = ch;
}

Now we can type, go right a few spaces, and continue typing. If you navigate left, you will notice you type over characters instead of inserting new ones. This may be what you want in your design, but in case you want to follow text editor standards, we need to fix this too:

#include <string.h>

    // insert characters instead of overwriting
    if( b->current->text[x] != 0 )
    {
        for( int i = strlen(b->current->text); i >= x; i-- )
        {
            b->current->text[i+1] = b->current->text[i];    
        }
    }


This works by shifting all the characters after and at the current position to the next slot in the array.

With horizontal entry works as expected, we can deal with moving down and up. Like our first section, this will be achieved by moving over (or creating) nodes until we reach our current y position. This should be done before the horizontal insertion piece:

// iterate until we reach our y node
    b->current = b->head;
    for( int i = 0; i < y; i++ )
    {
        if( b->current->next == NULL )
        {
            b->current->next = calloc(1, sizeof(node_t));
        }
        b->current = b->current->next;
    }

We also need to modify our printing code to account for multiple lines:

        // print text
        int line = 0;
        node_t *iter = b.head;
        while( iter != NULL ) 
        {
            mvprintw(line++, 0, iter->text);
            iter = iter->next;
        }

Navigation and text insertion now work as expected.


Deleting

Implementing deleting use a similar idea to inserting: shift all the letters back one and throw a null character on the previously last character.

Since a lot of the operations require selecting the current node, we should extract this functionality:

void buffer_select_node(Buffer *b, int y)
{
    if(b->head == NULL)
    {
        b->head = calloc(1, sizeof(node_t));
        b->current = b->head;
    }

    // iterate until we reach our y node
    b->current = b->head;
    for( int i = 0; i < y; i++ )
    {
        if( b->current->next == NULL )
        {
            b->current->next = calloc(1, sizeof(node_t));
        }
        b->current = b->current->next;
    }
}

The remove function:

void buffer_remove_character(Buffer *b, int x, int y)
{
    buffer_select_node(b, y);

    for ( int i = x - 1; i < strlen(b->current->text); i++ )
    {
        b->current->text[i] = b->current->text[i + 1];
    }

    b->current->text[strlen(b->current->text)] = 0;    
}

And finally we add our case in the input processing:

#define KEY_DELETE 127

case KEY_DELETE:
    buffer_remove_character(&b, x, y);
    x--;
    break;

Deleting has some edge-cases to consider. The most important is the functionality if you delete at the beginning of a line. Most text-editors will take line and append it to the previous line. This can be done by using strcat or equivalent and then nulling out the current line.


Scrolling and Paging

There are multiple different ways to handle text datasets that do not fit on a single screen. For example, nano scrolls up and down, but pages the whole screen left and right. Vim scrolls up and down, but wraps content left and right. Clunk scrolls both ways, but initially paged.

Because of all the variations, I am not going to cover them all. A lot of them involve keeping track of the current scroll or page amount and using that to increment the draw. Something like:

        int line = 0;
        node_t *iter = b.head;
        
        for( int i = 0; i < scroll_y; i++ )
            iter = iter->next;
        
        char buffer[128] = { 0 };
        while( iter != NULL ) 
        {
            strncpy(buffer, iter->text + scroll_x, COLS - 1);
            
            mvprintw(line++, 0, iter->text);
            iter = iter->next;
        }

These need to be kept track of when modifying the buffer. Paging can be accomplished in a similar way.