DEV Community

Ilean Monterrubio Jr
Ilean Monterrubio Jr

Posted on • Originally published at ilean.me on

Building a Terminal Text Editor: The Model (Part 1)

The Idea

The idea for wordNebula came from seeing an interview with George R.R. Martin, where he talks about how he writes his books on a really old computer because his preferred word processor can't run on anything new. After finding out that the program is called WordStar, and some minor Googling, I saw it was entirely terminal-based, and thought that idea was novel.

Growing up in the 90s, I went to an elementary school that gave us weekly computer lab time. I got the chance to use 5.25" floppy disks to play text-based games, and later learned to use the Macintosh computers and play the new versions of The Oregon Trail. So I can see how a writer can feel nostalgic and want to keep using a tool — if it's not broken, why change it?

I am also a software engineer who has used Vim and Neovim, and I see why they have such great appeal. Those tools keep improving and staying up to date, and they are great at being distraction-free, keeping the user's focus on the task at hand.

Why Build Another One?

Starting this project, I did a bit of research. Terminal word processors already exist. The open-source one I found is called WordGrinder — it has been around for a while and is written in C and Lua. It is a great project; check it out if you have the time. While terminal word processors are no longer popular, many writers still use them for the limited distraction and focus they offer.

Honestly, I just thought it was something cool to build, and I just wanted something to work on for me. The overall stretch goal is to make wordNebula a full writer's tool — it would format the output for the user based on a config file or similar setup. The idea is that playwrights and screenwriters could fully write without any distraction or worrying about formatting, and novelists could structure their work by chapter. But that is the future; for now, it is a portfolio project that lets me explore terminal UI development, data structures, and architecture patterns in C++.

Choosing an Architecture

For this project, I decided early on to use modern C++ targeting C++20. Before diving into implementation, my first architectural decision was choosing a pattern: Model-View-Controller (MVC), Model-View-Presenter (MVP), or the newer Model-View-ViewModel (MVVM).

Here is a quick comparison:

Aspect Model-View-Controller Model-View-Presenter Model-View-ViewModel
Mediator Controller Presenter ViewModel
View Role Active, interacts with Model directly Passive, passes data to Presenter Passive, data is bound to ViewModel
Data Binding Manual updates Manual, Presenter sends to Model Automatic via data binding
Testability Low Good Best
Separation Basic Better Best
Best For Small projects Medium to complex Large, data-driven UIs

After researching these patterns and studying block diagrams, I picked Model-View-Presenter. The main reason is that it completely separates all three concerns:

  • The Model is the text buffer. Its only job is maintaining the buffer — storing text, tracking the cursor, and supporting insertions and deletions.
  • The View handles the UI layout, how the data is displayed, and capturing the user's key inputs, which it passes to the Presenter.
  • The Presenter is the orchestrator. It receives input from the View, passes text operations to the Model, then requests the updated data from the Model to refresh the View.

All communication flows through the Presenter. This is the main reason the architecture is testable — and while we all know unit tests aren't fun, we understand why they're important.

Getting My Hands Dirty

With the architecture decision made and the clean separation of concerns giving me the ability to work on different parts of the project independently, I started with a simple proof of concept. I implemented a basic Model text buffer that accepts keystrokes and prints to a log file. It wasn't perfect, but it got me to understand the basic structure and play a bit with MVP before committing to the full implementation.

For this I set up what I called a TextBuffer, which was just a string where I added things to it via a simple Presenter and View with ncurses. I had Copilot help me test it, and what I learned was that I should really start the project by implementing the Model first — it is such a critical part of the project, and it prompted me to do more research on how word processors handle their buffers.

The Model: Choosing a Data Structure

I started doing research on text buffers and saw some really appealing ones. One was the Gap Buffer, which I believe Emacs uses. Another was the Piece Table, used by Microsoft Word, and I found a blog from the VS Code team about their usage of it and their findings: Text Buffer Reimplementation.

After observing this and understanding the differences between them, I picked the Gap Buffer for the initial implementation. This decision became clear once I started to define the use cases — since this is a terminal-based UI, users probably won't be highlighting and removing large chunks of text, so undo and redo can be implemented much later. And also a key difference from any modern code editor: we don't need multiple cursors, only a single one where the user can add to the document.

The idea behind a Gap Buffer is straightforward. You have an array with a "gap" of empty space that follows the cursor around. When you type, you drop characters into the gap. When you move the cursor, the gap moves with it. Here is what that looks like:

Before: [H][e][l][l][o][___GAP___][W][o][r][l][d]
                        ^ cursor

Insert ',':
After: [H][e][l][l][o][,][__GAP__][W][o][r][l][d]
                           ^ cursor

Enter fullscreen mode Exit fullscreen mode

The core of the insertion is simple — move the gap to the cursor, make room if needed, and drop the character in:

void GapBuffer::insertChar(char c) {
    moveGapToCursor();

    if (getGapSize() < 1) {
        expandGap();
    }

    buffer[gapStart] = c;
    gapStart++;
    cursor++;
}

Enter fullscreen mode Exit fullscreen mode

It was also key to use interface abstract classes to make sure I could update the buffer if I ever make a new implementation. This was very important to me because moving into the future we might need flexibility — being able to add undo/redo, or add styling. I was so unsure early on that I thought having that flexibility was very important to have.

The IBuffer interface defines the full contract that any buffer must fulfill. Here is a trimmed version showing the method signatures:

class IBuffer {
  public:
    virtual ~IBuffer() = default;

    // Text Operations
    virtual void insertChar(char c) = 0;
    virtual void insertText(const std::string &text) = 0;
    virtual void deleteChar() = 0;
    virtual void deleteForward() = 0;

    // Text Access
    virtual std::string getText() const = 0;
    virtual std::string getTextRange(int start, int length) const = 0;
    virtual int getLength() const = 0;

    // Cursor Management
    virtual int getCursorPosition() const = 0;
    virtual void setCursorPosition(int position) = 0;
    virtual void moveCursor(int offset) = 0;

    // Smart Navigation
    virtual int findNextWordBoundary(int fromPos) const = 0;
    virtual int findPrevWordBoundary(int fromPos) const = 0;
    virtual int findNextParagraph(int fromPos) const = 0;
    virtual int findPrevParagraph(int fromPos) const = 0;

    // Statistics
    virtual int getWordCount() const = 0;
    virtual int getParagraphCount() const = 0;
};

Enter fullscreen mode Exit fullscreen mode

With this interface, if I ever need to swap in a Piece Table for advanced undo/redo support down the road, I can do it without rewriting the Presenter or View. The full interface with detailed Doxygen documentation is in the repository.

What the Model Can Do So Far

The Model can fully add text at the cursor position, and the gap can grow dynamically. It supports full cursor navigation, text insertion and deletion, word count, and paragraph count. It also has a comprehensive test suite with Google Test. The Model stands on its own — it was built and tested before the Presenter or View ever existed.

What's Next

In Part 2 I will go over the Presenter, which takes the Model and orchestrates everything — coordinating between the buffer and the UI, and managing application state. The Presenter and View are already implemented; you can check out the project on GitHub.

Top comments (0)