DEV Community

Ilean Monterrubio Jr
Ilean Monterrubio Jr

Posted on • Originally published at ilean.me on

Construyendo un editor de texto de terminal: la Vista (Parte 3)

Resumen

En la Parte 1 hablé del origen de este proyecto, las primeras decisiones de arquitectura y por qué elegí el GapBuffer con IBuffer como el contrato para todas las futuras implementaciones de búfer. En la Parte 2 entramos en los detalles del Presentador, cómo conecta los componentes, maneja la entrada del usuario, gestiona el estado y mantiene el Modelo y la Vista sincronizados. Ahora podemos pasar a la Vista.

¿Qué es la Vista?

La Vista es el componente con el que el usuario interactúa directamente. Todas las entradas del usuario se recopilan y se pasan al Presentador; la Vista no decide qué hacer con ellas. También es responsable de renderizar el texto que el usuario escribe, junto con cualquier advertencia, estadísticas como el conteo de palabras y si el archivo está sin guardar. La Vista no calcula nada de esto, solo muestra lo que el Presentador le envía. Otro ejemplo de cómo las tres partes de Modelo-Vista-Presentador separan las responsabilidades.

Por qué FTXUI en lugar de ncurses

En las pruebas de la prueba de concepto usé ncurses, la biblioteca estándar de facto para construir interfaces de terminal. Funcionó bien para las pruebas iniciales, y veo muchos proyectos que todavía la usan; es confiable y probada. Aunque ncurses habría sido una gran elección, investigué un poco más y encontré FTXUI, una biblioteca de C++ moderna con componentes para construir interfaces de usuario de terminal interactivas. Quería aprovechar que usa C++ moderno, en línea con la especificación que definí al inicio del proyecto de apuntar a C++20.

La interfaz IView

Mientras implementaba los otros componentes (el Modelo y el Presentador), armé una interfaz ligera para la Vista, IView. Al principio solo tomaba la entrada de caracteres y no contemplaba los caracteres especiales, ni se encargaba de renderizar dato alguno. La Vista de la prueba de concepto solo tomaba la entrada, la enviaba al Presentador, que la mandaba al búfer y la registraba para que yo pudiera ver que los datos realmente fluían por el sistema.

Pensaba en eliminarla, pero después de investigar más, decidí más bien expandir la interfaz. Mantener IView como una clase abstracta significa que, si alguien quiere escribir su propia Vista para un sistema operativo que FTXUI no soporta, puede implementar la interfaz y conectarla sin tocar el Presentador ni el Modelo.

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;
};

Enter fullscreen mode Exit fullscreen mode

Una explicación rápida: run() inicia el bucle de eventos y recibe un callback para manejar la entrada; esto es lo que el Presentador llama en su bucle principal. render() toma el ViewState que vimos en la Parte 2 y lo dibuja en la pantalla. exit() y getTerminalSize() hacen lo que dicen, y showMessage() es la forma en que el Presentador muestra advertencias como el aviso de "cambios sin guardar" del flujo de salida.

Renderizando la interfaz

Como mostramos en la Parte 2, la estructura ViewState define el contrato de cómo el Presentador envía datos a la Vista:

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
};

Enter fullscreen mode Exit fullscreen mode

Hay tres grandes tipos de datos en el contrato de ViewState: Contenido , que nos dice la cursorPosition y el visibleText. Información de la barra de estado : wordCount, filename e isDirty, que indica si el archivo está sin guardar. Por último, el estado de la interfaz : statusMessage y showHelp.

Uno de los mayores retos fue cómo lidiar con el cursor, cómo moverlo y poder agregar más al búfer cuando el cursor se mueve. Una de las soluciones más simples es dividir el visibleText en tres componentes: beforeCursor, cursor y 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});
}

Enter fullscreen mode Exit fullscreen mode

El cursor se resalta con un fondo cian para que el usuario pueda ver dónde está en el texto. Este reto existía tanto en ncurses como en FTXUI; ninguna biblioteca te da un cursor integrado para un editor de texto personalizado. Tienes que simularlo tú mismo.

Traduciendo la entrada del teclado

La Vista también es responsable de traducir los eventos crudos del teclado a las estructuras InputEvent que el Presentador entiende. FTXUI tiene su propio sistema de eventos, así que la Vista necesita una capa de traducción entre lo que FTXUI nos da y lo que nuestro Presentador espera. El método translateEvent() se encarga de este mapeo:

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};
}

Enter fullscreen mode Exit fullscreen mode

El patrón es sencillo: cada evento de FTXUI se mapea a uno de nuestros tipos InputEvent. Si es un carácter imprimible, extraemos el valor del carácter. Si no reconocemos el evento, devuelve UNKNOWN y se ignora. Esta es la misma capa de traducción que habría que reimplementar si alguien escribiera una nueva Vista usando una biblioteca distinta, otro beneficio de la interfaz IView.

Lo que la Vista puede hacer hasta ahora

La Vista funciona bien para el estado actual del proyecto. Renderiza el texto conforme el usuario escribe, muestra la barra de estado con el nombre del archivo, el conteo de palabras y el indicador de cambios sin guardar, captura todos los eventos de entrada y los enruta al Presentador, y alterna la superposición de ayuda con F1. No es perfecta: los saltos de línea todavía no se renderizan y las teclas de flecha no pueden navegar del todo hacia arriba y hacia abajo por el texto. Pero para la Fase 1, cumple su trabajo como la capa pasiva del patrón MVP.

Cerrando la serie

Con esto cerramos la serie de tres partes sobre la construcción de la Fase 1 de wordNebula. A lo largo de estas entradas fuimos desde el origen del proyecto y por qué elegí Modelo-Vista-Presentador, pasando por el Modelo con el GapBuffer y la interfaz IBuffer, el Presentador que orquesta todo y gestiona el estado, y finalmente la Vista que lo renderiza todo en la terminal.

Construir cada capa de forma independiente y conectarlas mediante contratos como IBuffer e IView hizo que el proyecto fuera manejable. Podía trabajar en una pieza a la vez sin preocuparme por romper las demás. Esa separación es justamente el propósito de MVP, y valió la pena.

La Fase 1 está completa pero lejos de estar terminada. Hay más por construir, pero por ahora me alejo para enfocarme en otros proyectos. Si quieres explorar el código, échale un vistazo al proyecto en GitHub.

Top comments (0)