DEV Community

Ilean Monterrubio Jr
Ilean Monterrubio Jr

Posted on • Originally published at ilean.me on

Construyendo un editor de texto de terminal: el Presentador (Parte 2)

Resumen

En la Parte 1 hablé del origen de este proyecto y de las primeras decisiones de arquitectura. Después de elegir Modelo-Vista-Presentador, dividir el proyecto en Modelo, Vista y Presentador se sintió como el camino natural. Abordamos el GapBuffer con IBuffer como el contrato para todas las futuras implementaciones de búfer. Con el Modelo sosteniéndose por sí solo, podemos pasar al Presentador.

¿Qué es el Presentador?

Ya insinuamos en la Parte 1 que el Presentador es el orquestador: el que maneja toda la lógica de "negocio" de la aplicación. Es responsable de mantener actualizados tanto el Modelo como la Vista. El Presentador toma la entrada del usuario desde la Vista y se la pasa al Modelo, luego obtiene los datos actualizados del Modelo y los envía de vuelta a la Vista para renderizarlos. Toda la comunicación entre ambos pasa por el Presentador.

Tomemos el caso de uso más común: un usuario presionando una sola tecla. En realidad estará escribiendo varias en sucesión, y este proceso se repite constantemente en la implementación actual.

Paso De A Qué ocurre
1 Usuario (actor) Vista Presiona una tecla
2 Vista Presentador Pasa el evento de tecla
3 Presentador Modelo Llama a insertChar()
4 Presentador Modelo Solicita el texto actualizado
5 Presentador Vista Envía el texto para renderizar

Vemos el gran papel que cumple el Presentador y exploraremos cómo creamos contratos entre los componentes para asegurar que los datos se pasen correctamente. Para wordNebula, esto también significa que el Presentador será donde viva la lógica futura.

Conectándolo todo

Como este proyecto usa C++ moderno, quise aprovechar los smart pointers y apoyarme en Resource Acquisition Is Initialization (RAII). De esta forma no tenemos que gestionar la memoria de forma explícita; es la práctica estándar en C++ moderno para cualquier proyecto de sistemas.

Al empezar a conectar los distintos componentes, incluyendo la Vista simulada de las pruebas originales que hice antes de abordar el proyecto completo, tuve que tomar algunas decisiones sobre la propiedad (ownership). Así se crean los tres componentes en 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

Los tres se crean como shared_ptr, pero dentro del Presentador se almacenan de forma diferente:

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

Enter fullscreen mode Exit fullscreen mode

El Modelo se queda como shared_ptr porque el Presentador necesita ser su dueño: son los datos centrales y deben existir mientras la aplicación esté corriendo. La Vista se almacena como weak_ptr para romper una dependencia circular. El Presentador necesita la Vista para enviar actualizaciones, y la Vista necesita al Presentador para enviar la entrada. Si ambos guardaran un shared_ptr el uno del otro, ninguno se liberaría jamás.

La contrapartida de weak_ptr es que cada vez que el Presentador necesita actualizar la Vista, tiene que convertirla temporalmente de vuelta a un shared_ptr llamando a lock(). Si la Vista todavía existe, lock() nos da un shared_ptr válido con el cual trabajar. Si no, sabemos que la Vista ya no está y podemos manejarlo de forma elegante. Esto ocurre dentro de updateView() cada vez que el Presentador necesita renderizar datos nuevos en la pantalla.

Manejando la entrada del usuario

En las pruebas exploratorias solo me preocupaba por la entrada de caracteres; era únicamente para comprobar si entendía la arquitectura. Para la implementación real, necesitaba realmente contemplar las teclas especiales: CTRL, BACKSPACE, DELETE, ARROW KEYS, ENTER, etc.

Para manejar esto de forma limpia, creé una estructura InputEvent que traduce la entrada cruda del teclado en eventos semánticos:

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

De esta forma al Presentador no le importan los códigos de escape de la terminal: solo recibe un tipo de evento y actúa en consecuencia. El método handleInput enruta cada evento a la operación correcta:

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

Cada operación sigue el mismo patrón: delegar en el Modelo, marcar como modificado (dirty) si hace falta y actualizar la Vista:

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

Enter fullscreen mode Exit fullscreen mode

Gestión del viewport

Una de las cosas que aprendí en el camino, o sobre la que tuve que pensar de verdad, fue el viewport. Hasta ese momento, las pruebas iniciales consistían en que yo escribía frases sencillas, no entradas de blog completas ni texto de formato más largo. El área de la terminal es finita, así que determinar qué porción del búfer mostrar y cómo presentarla fue todo un reto.

El Presentador resuelve esto construyendo un ViewState: una estructura que empaqueta todo lo que la Vista necesita para renderizar:

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

Enter fullscreen mode Exit fullscreen mode

Cada vez que el Presentador actualiza la Vista, obtiene el texto actual y la posición del cursor del Modelo y los envía como un ViewState. La Vista no sabe nada del búfer: solo renderiza cualquier estado que reciba. Aquí está el método updateView() que construye y envía ese estado:

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

Fíjate en el view.lock(): es la conversión de weak_ptr de la que hablamos en la sección de conexión. Cada vez que el Presentador necesita actualizar la Vista, comprueba que la Vista todavía exista antes de enviar datos.

Gestión del estado

Para gestionar el estado en esta iteración del proyecto lo mantuve simple. El Presentador rastrea unas cuantas banderas booleanas: isDirty para saber cuándo el archivo no se ha guardado, isRunning para controlar el bucle principal, showHelp para alternar la superposición de ayuda y exitWarningShown para la confirmación de salida.

El más interesante es el flujo de salida. Si tienes cambios sin guardar y presionas Ctrl+Q, el Presentador te avisa y te hace presionarlo de nuevo para salir de verdad:

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

Lo que el Presentador puede hacer hasta ahora

En esta etapa, el Presentador recibe todos los eventos de entrada y determina si se trata de una tecla especial o un carácter, gestiona por completo el estado de la aplicación y del documento, y mantiene el Modelo y la Vista sincronizados. Es el pegamento entre ambos y, gracias a MVP, se puede probar de forma independiente.

Lo que sigue

En la Parte 3 hablaré de por qué elegí FTXUI en lugar de ncurses y de lo que ofrece de fábrica que hizo más fácil construir la capa de la Vista. Puedes ver el proyecto en GitHub.

Top comments (0)