Recap
In Part 1, I talked about the origin of this project, the early architectural decisions, and why I picked the GapBuffer with IBuffer as the contract for all future buffer implementations. In Part 2, we got into the details of the Presenter, how it wires the components together, handles user input, manages state, and keeps the Model and View in sync. Now we can move on to the View.
What is the View?
The View is the component that the user interacts with directly. All user inputs are collected and passed to the Presenter, the View doesn't decide what to do with them. It is also responsible for rendering the text the user types, along with any warnings, statistics like word count, and whether the file is unsaved. The View doesn't calculate any of this, it just displays whatever the Presenter sends it. Another example of how all three parts of the Model-View-Presenter separate concerns.
Why FTXUI instead of ncurses
In the proof of concept testing, I used ncurses, the de facto standard library for building terminal interfaces. It worked well for the initial testing, and I see a lot of projects still using it; it's tried and true. While ncurses would have been a great choice, I did a bit more research and found FTXUI, a modern C++ library with components to build interactive terminal user interfaces. I wanted to leverage that it uses modern C++, matching the project's early defined specification of targeting C++20.
The IView Interface
While implementing the other components (Model and Presenter), I mocked up a thin interface for the View, IView. At first, it was only taking character input and not accounting for special characters, and it didn't handle rendering any data. The proof of concept View just took the input, sent it to the Presenter, which would send it to the buffer and log it so I could see the data was actually flowing through the system.
I was thinking of removing it, but after doing more research, I decided to expand the interface instead. Keeping IView as an abstract class means that if someone wants to write their own View for an OS that isn't supported by FTXUI, they can implement the interface and plug it in without touching the Presenter or Model.
class IView {
public:
virtual ~IView() = default;
virtual void run(std::function<void(const InputEvent &)> onInput) = 0;
virtual void render(const ViewState &state) = 0;
virtual void exit() = 0;
virtual std::pair<int, int> getTerminalSize() const = 0;
virtual void showMessage(const std::string &message, bool isError = false) = 0;
};
A quick explanation, run() starts the event loop and takes a callback for handling input, this is what the Presenter calls in its main loop. render() takes the ViewState we saw in Part 2 and draws it to the screen. exit() and getTerminalSize() do what they say, and showMessage() is how the Presenter displays warnings like the "unsaved changes" prompt from the exit flow.
Rendering the UI
As we showed in Part 2, the ViewState struct defines the contract for how the Presenter sends data to the View:
struct ViewState {
// Content
std::string visibleText; // Text currently visible in viewport
int cursorPosition; // Linear position of cursor in visibleText
// Status bar information
int wordCount; // Total word count (primary metric for writers)
std::string filename; // Current file name (or "Untitled")
bool isDirty; // Unsaved changes indicator
// UI state
std::string statusMessage; // Temporary status message (e.g., "Saved", "Error: ...")
bool showHelp; // Whether to show help overlay
};
There are three major types of data in the ViewState contract: Content , which tells us the cursorPosition and the visibleText. Status bar information : wordCount, filename, and isDirty which indicates whether the file is unsaved. Finally, the UI state : statusMessage and showHelp.
One of the biggest challenges was how to deal with the cursor, how to move it, and be able to add more to the buffer when the cursor moves. One of the simplest solutions is to break the visibleText into three components: beforeCursor, cursor, and afterCursor.
ftxui::Element FtxuiView::renderEditor() {
const auto &text = currentState.visibleText;
const size_t pos = static_cast<size_t>(currentState.cursorPosition);
auto before = ftxui::text(text.substr(0, pos));
// Character at cursor with cyan background
std::string cursorChar = pos < text.size() ? std::string(1, text[pos]) : " ";
auto cursor = ftxui::text(cursorChar) | ftxui::bgcolor(ftxui::Color::Cyan) | ftxui::color(ftxui::Color::Black);
auto after = pos + 1 < text.size() ? ftxui::text(text.substr(pos + 1)) : ftxui::text("");
return ftxui::hbox({before, cursor, after});
}
The cursor is highlighted with a cyan background so the user can see where they are in the text. This challenge existed in both ncurses and FTXUI, neither library gives you a built-in cursor for a custom text editor. You have to simulate it yourself.
Translating Keyboard Input
The View is also responsible for translating raw keyboard events into the InputEvent structs that the Presenter understands. FTXUI has its own event system, so the View needs a translation layer between what FTXUI gives us and what our Presenter expects. The translateEvent() method handles this mapping:
InputEvent FtxuiView::translateEvent(const ftxui::Event &event) {
if (event == ftxui::Event::ArrowLeft)
return {InputEvent::Type::ARROW_LEFT};
if (event == ftxui::Event::ArrowRight)
return {InputEvent::Type::ARROW_RIGHT};
if (event == ftxui::Event::Backspace)
return {InputEvent::Type::BACKSPACE};
if (event == ftxui::Event::Return)
return {InputEvent::Type::ENTER};
if (event == ftxui::Event::CtrlQ)
return {InputEvent::Type::CTRL_Q};
if (event == ftxui::Event::CtrlS)
return {InputEvent::Type::CTRL_S};
// Printable character
if (event.is_character()) {
InputEvent ie{InputEvent::Type::CHARACTER};
ie.character = event.character()[0];
return ie;
}
return {InputEvent::Type::UNKNOWN};
}
The pattern is straightforward, each FTXUI event maps to one of our InputEvent types. If it's a printable character, we extract the character value. If we don't recognize the event, it returns UNKNOWN and gets ignored. This is the same translation layer that would need to be reimplemented if someone wrote a new View using a different library, another benefit of the IView interface.
What the View Can Do So Far
The View works well for the current state of the project. It renders text as the user types, displays the status bar with the filename, word count, and unsaved changes indicator, captures all input events and routes them to the Presenter, and toggles the help overlay with F1. It is not perfect, newlines don't render yet and the arrow keys can't fully navigate up and down through the text. But for Phase 1, it does its job as the passive layer in the MVP pattern.
Wrapping Up the Series
This wraps up the three-part series on building wordNebula's Phase 1. Across these posts, we went from the origin of the project and why I chose Model-View-Presenter, through the Model with the GapBuffer and IBuffer interface, the Presenter that orchestrates everything and manages state, and finally the View that renders it all to the terminal.
Building each layer independently and connecting them through contracts like IBuffer and IView made the project manageable. I could work on one piece at a time without worrying about breaking the others. That separation is the whole point of MVP, and it paid off.
Phase 1 is complete but far from finished. There's more to build, but for now I'm stepping away to focus on other projects. If you want to explore the code, check out the project on GitHub.
Top comments (0)