DEV Community

Ilean Monterrubio Jr
Ilean Monterrubio Jr

Posted on • Originally published at ilean.me on

Building a Terminal Text Editor: The Presenter (Part 2)

Recap

In Part 1, I talked about the origin of this project and the early architectural decisions. After picking Model-View-Presenter, breaking up the project into Model, View, and Presenter felt like the natural path forward. We tackled the GapBuffer with IBuffer as the contract for all future buffer implementations. With the Model standing on its own, we can move on to the Presenter.

What is the Presenter?

We already hinted in Part 1 that the Presenter is the orchestrator — the one that handles all the application's "business" logic. It is responsible for keeping both the Model and the View up to date. The Presenter takes user input from the View and passes it to the Model, then pulls the updated data from the Model and sends it back to the View for rendering. All communication between the two goes through the Presenter.

Let's take the most common use case of a user pressing a single key stroke. In reality, they will be typing several in succession, and this process gets repeated constantly in the current implementation.

Step From To What Happens
1 User (actor) View Presses key
2 View Presenter Passes the key event
3 Presenter Model Calls insertChar()
4 Presenter Model Requests updated text
5 Presenter View Sends text to render

We see the big role that the Presenter fills and we will explore how we created contracts between the components to ensure that data is passed correctly. For wordNebula, this also means the Presenter will be where future logic lives.

Wiring It Together

Since this project is using modern C++ I wanted to take advantage of smart pointers and leverage Resource Acquisition Is Initialization (RAII). This way we don't have to explicitly manage memory — this is standard practice in modern C++ for any systems project.

When starting to wire up the different components, including the mock View from the original testing I did before tackling the full project, I had to make some decisions on ownership. Here is how the three components get created in main:

const auto presenter = std::make_shared<WNebulaPresenter>();
const auto model = std::make_shared<WNebulaModel>();
const auto view = std::make_shared<FtxuiView>();

presenter->setup(view, model);
presenter->run();

Enter fullscreen mode Exit fullscreen mode

All three are created as shared_ptr, but inside the Presenter they are stored differently:

std::weak_ptr<IView> view;
std::shared_ptr<WNebulaModel> model;

Enter fullscreen mode Exit fullscreen mode

The Model stays as a shared_ptr because the Presenter needs to own it — it is the core data and must exist as long as the application is running. The View is stored as a weak_ptr to break a circular dependency. The Presenter needs the View to push updates, and the View needs the Presenter to send input. If both held shared_ptr to each other, neither would ever get cleaned up.

The trade-off with weak_ptr is that every time the Presenter needs to update the View, it has to convert it back to a shared_ptr temporarily by calling lock(). If the View still exists, lock() gives us a valid shared_ptr to work with. If it doesn't, we know the View is gone and can handle it gracefully. This happens inside updateView() every time the Presenter needs to render new data to the screen.

Handling User Input

In the exploratory testing, I only worried about character user input — it was just to test if I understood the architecture. For the real implementation, I needed to actually account for special keys: CTRL, BACKSPACE, DELETE, ARROW KEYS, ENTER, and so on.

To handle this cleanly, I created an InputEvent struct that translates raw keyboard input into semantic events:

struct InputEvent {
    enum class Type {
        CHARACTER,
        ARROW_LEFT, ARROW_RIGHT, ARROW_UP, ARROW_DOWN,
        CTRL_LEFT, CTRL_RIGHT, CTRL_UP, CTRL_DOWN,
        HOME, END,
        BACKSPACE, DELETE, ENTER,
        CTRL_S, CTRL_Q,
        ESCAPE,
        // ... other types
    };

    Type type;
    char character = '\0'; // Only valid for Type::CHARACTER
};

Enter fullscreen mode Exit fullscreen mode

This way the Presenter doesn't care about terminal escape codes — it just receives an event type and acts on it. The handleInput method routes each event to the right operation:

void WNebulaPresenter::handleInput(const InputEvent &event) {
    switch (event.type) {
    case InputEvent::Type::CHARACTER:
        onInsert(event.character);
        break;
    case InputEvent::Type::BACKSPACE:
        onDelete();
        break;
    case InputEvent::Type::CTRL_LEFT:
        onCtrlLeft();
        break;
    case InputEvent::Type::CTRL_Q:
        onExit();
        break;
    // ... other cases
    }
}

Enter fullscreen mode Exit fullscreen mode

Each operation follows the same pattern — delegate to the Model, mark dirty if needed, update the View:

void WNebulaPresenter::onInsert(char c) {
    model->insertChar(c);
    isDirty = true;
    updateView();
}

Enter fullscreen mode Exit fullscreen mode

Viewport Management

One of the things I learned along the way, or had to really think about, was the viewport. So far, the initial testing was me writing simple sentences, not full blog entries or longer format text. The area of the terminal is finite, so determining what portion of the buffer to display and how to present it was a challenge.

The Presenter solves this by building a ViewState — a struct that packages everything the View needs to render:

struct ViewState {
    std::string visibleText;
    int cursorPosition;
    int wordCount;
    std::string filename;
    bool isDirty;
    bool showHelp;
};

Enter fullscreen mode Exit fullscreen mode

Every time the Presenter updates the View, it pulls the current text and cursor position from the Model and sends it as a ViewState. The View doesn't know anything about the buffer — it just renders whatever state it receives. Here is the updateView() method that builds and sends that state:

void WNebulaPresenter::updateView() {
    if (auto v = view.lock()) {
        ViewState state{};
        state.visibleText = model->getText();
        state.cursorPosition = model->getCursorPosition();
        state.wordCount = model->getWordCount();
        state.filename = currentFilePath.empty() ? "Untitled" : currentFilePath;
        state.isDirty = isDirty;
        state.showHelp = showHelp;
        v->render(state);
    }
}

Enter fullscreen mode Exit fullscreen mode

Notice the view.lock() — this is the weak_ptr conversion we talked about in the Wiring section. Every time the Presenter needs to update the View, it checks that the View still exists before sending data.

State Management

For managing state in this iteration of the project I kept it simple. The Presenter tracks a few boolean flags — isDirty to know when the file hasn't been saved, isRunning to control the main loop, showHelp to toggle the help overlay, and exitWarningShown for the quit confirmation.

The most interesting one is the exit flow. If you have unsaved changes and press Ctrl+Q, the Presenter warns you and makes you press it again to actually quit:

void WNebulaPresenter::onExit() {
    if (isDirty && !exitWarningShown) {
        exitWarningShown = true;
        if (auto v = view.lock()) {
            v->showMessage("Unsaved changes! Press Ctrl+Q again to quit.", true);
        }
        return;
    }
    isRunning = false;
    if (auto v = view.lock()) {
        v->exit();
    }
}

Enter fullscreen mode Exit fullscreen mode

What the Presenter Can Do So Far

At this stage, the Presenter takes in all input events and determines if it is a special key or character, fully manages the state of the application and document, and keeps the Model and View in sync. It is the glue between the two, and because of MVP, it can be tested independently.

What's Next

In Part 3, I will talk about why I picked FTXUI instead of ncurses and what it offers out of the box that made the View layer easier to build. You can check out the project on GitHub.

Top comments (0)