DEV Community

Ilean Monterrubio Jr
Ilean Monterrubio Jr

Posted on • Originally published at ilean.me on

Construyendo un editor de texto de terminal: el Modelo (Parte 1)

La idea

La idea de wordNebula surgió al ver una entrevista con George R.R. Martin, donde cuenta cómo escribe sus libros en una computadora muy vieja porque su procesador de texto preferido no corre en nada nuevo. Después de enterarme de que el programa se llama WordStar, y de googlear un poco, vi que era completamente basado en terminal, y me pareció una idea original.

Al crecer en los 90, fui a una primaria que nos daba tiempo semanal en el laboratorio de cómputo. Tuve la oportunidad de usar disquetes de 5.25" para jugar juegos basados en texto, y más adelante aprendí a usar las computadoras Macintosh y a jugar las nuevas versiones de The Oregon Trail. Así que entiendo cómo un escritor puede sentirse nostálgico y querer seguir usando una herramienta: si no está roto, ¿para qué cambiarlo?

También soy ingeniero de software y he usado Vim y Neovim, y entiendo por qué tienen tanto atractivo. Esas herramientas siguen mejorando y manteniéndose al día, y son excelentes para trabajar sin distracciones, manteniendo la atención del usuario en la tarea que tiene enfrente.

¿Por qué construir otro?

Al empezar este proyecto investigué un poco. Los procesadores de texto de terminal ya existen. El de código abierto que encontré se llama WordGrinder; lleva un buen tiempo por ahí y está escrito en C y Lua. Es un gran proyecto; échale un vistazo si tienes tiempo. Aunque los procesadores de texto de terminal ya no son populares, muchos escritores todavía los usan por la poca distracción y el enfoque que ofrecen.

Honestamente, solo me pareció algo padre de construir, y quería algo en lo que trabajar para mí. La meta ambiciosa a largo plazo es convertir a wordNebula en una herramienta completa para escritores: daría formato a la salida según un archivo de configuración o algo similar. La idea es que dramaturgos y guionistas pudieran escribir por completo sin ninguna distracción ni preocuparse por el formato, y que los novelistas pudieran estructurar su trabajo por capítulos. Pero eso es a futuro; por ahora es un proyecto de portafolio que me permite explorar el desarrollo de interfaces de terminal, las estructuras de datos y los patrones de arquitectura en C++.

Eligiendo una arquitectura

Para este proyecto decidí desde el principio usar C++ moderno apuntando a C++20. Antes de meterme de lleno en la implementación, mi primera decisión de arquitectura fue elegir un patrón: Modelo-Vista-Controlador (MVC), Modelo-Vista-Presentador (MVP) o el más nuevo Modelo-Vista-ViewModel (MVVM).

Aquí va una comparación rápida:

Aspecto Modelo-Vista-Controlador Modelo-Vista-Presentador Modelo-Vista-ViewModel
Mediador Controlador Presentador ViewModel
Rol de la Vista Activa, interactúa directamente con el Modelo Pasiva, pasa los datos al Presentador Pasiva, los datos se enlazan al ViewModel
Enlace de datos Actualizaciones manuales Manual, el Presentador envía al Modelo Automático mediante data binding
Testeabilidad Baja Buena La mejor
Separación Básica Mejor La mejor
Ideal para Proyectos pequeños De medianos a complejos Interfaces grandes, orientadas a datos

Después de investigar estos patrones y estudiar diagramas de bloques, elegí Modelo-Vista-Presentador. La razón principal es que separa por completo las tres responsabilidades:

  • El Modelo es el búfer de texto. Su único trabajo es mantener el búfer: almacenar el texto, rastrear el cursor y permitir inserciones y eliminaciones.
  • La Vista se encarga del diseño de la interfaz, de cómo se muestran los datos y de capturar las teclas que presiona el usuario, las cuales pasa al Presentador.
  • El Presentador es el orquestador. Recibe la entrada de la Vista, pasa las operaciones de texto al Modelo y luego solicita los datos actualizados al Modelo para refrescar la Vista.

Toda la comunicación fluye a través del Presentador. Esta es la razón principal por la que la arquitectura es testeable, y aunque todos sabemos que las pruebas unitarias no son divertidas, entendemos por qué son importantes.

Manos a la obra

Con la decisión de arquitectura tomada y la separación limpia de responsabilidades dándome la posibilidad de trabajar en distintas partes del proyecto de forma independiente, empecé con una prueba de concepto sencilla. Implementé un búfer de texto básico del Modelo que acepta pulsaciones de teclas y las escribe en un archivo de registro. No era perfecto, pero me sirvió para entender la estructura básica y jugar un poco con MVP antes de comprometerme con la implementación completa.

Para esto armé lo que llamé un TextBuffer, que era simplemente una cadena a la que le agregaba cosas mediante un Presentador y una Vista sencillos con ncurses. Dejé que Copilot me ayudara a probarlo, y lo que aprendí fue que en realidad debía empezar el proyecto implementando primero el Modelo: es una parte tan crítica del proyecto que me llevó a investigar más sobre cómo los procesadores de texto manejan sus búferes.

El Modelo: eligiendo una estructura de datos

Empecé a investigar sobre búferes de texto y vi algunos muy atractivos. Uno era el Gap Buffer, que creo que usa Emacs. Otro era la Piece Table, que usa Microsoft Word, y encontré un blog del equipo de VS Code sobre su uso y sus hallazgos: Text Buffer Reimplementation.

Después de observar esto y entender las diferencias entre ellos, elegí el Gap Buffer para la implementación inicial. Esta decisión se aclaró en cuanto empecé a definir los casos de uso: como se trata de una interfaz basada en terminal, lo más probable es que los usuarios no estén seleccionando y eliminando grandes bloques de texto, así que deshacer y rehacer se pueden implementar mucho después. Y otra diferencia clave respecto a cualquier editor de código moderno: no necesitamos múltiples cursores, solo uno con el que el usuario pueda agregar al documento.

La idea detrás de un Gap Buffer es sencilla. Tienes un arreglo con un "hueco" (gap) de espacio vacío que sigue al cursor a todos lados. Cuando escribes, vas soltando caracteres en el hueco. Cuando mueves el cursor, el hueco se mueve con él. Así se ve:

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

El núcleo de la inserción es simple: mueve el hueco hacia el cursor, haz espacio si hace falta y suelta el carácter:

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

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

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

Enter fullscreen mode Exit fullscreen mode

También fue clave usar clases abstractas de interfaz para asegurarme de poder actualizar el búfer si alguna vez hago una nueva implementación. Esto fue muy importante para mí porque de cara al futuro podríamos necesitar flexibilidad: poder agregar deshacer/rehacer o agregar estilos. Al principio estaba tan inseguro que pensé que tener esa flexibilidad era muy importante.

La interfaz IBuffer define el contrato completo que cualquier búfer debe cumplir. Aquí va una versión recortada que muestra las firmas de los métodos:

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

Con esta interfaz, si alguna vez necesito cambiar a una Piece Table para soportar deshacer/rehacer avanzado más adelante, puedo hacerlo sin reescribir el Presentador ni la Vista. La interfaz completa con documentación detallada de Doxygen está en el repositorio.

Lo que el Modelo puede hacer hasta ahora

El Modelo puede agregar texto por completo en la posición del cursor, y el hueco puede crecer de forma dinámica. Soporta navegación completa del cursor, inserción y eliminación de texto, conteo de palabras y conteo de párrafos. También tiene un conjunto de pruebas exhaustivo con Google Test. El Modelo se sostiene por sí solo: se construyó y se probó antes de que el Presentador o la Vista siquiera existieran.

Lo que sigue

En la Parte 2 repasaré el Presentador, que toma el Modelo y orquesta todo: coordina entre el búfer y la interfaz, y gestiona el estado de la aplicación. El Presentador y la Vista ya están implementados; puedes ver el proyecto en GitHub.

Top comments (0)