Ir al contenido
  1. Posts/

Todo TUI en Rust 5. Polling de eventos, el corte vertical de edición y cierre

·7 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
Todo TUI en Rust - Este artículo es parte de una serie.
Parte 5: Este artículo

Cerramos la serie.

El post anterior dio a la rata habilidades de emplatado: colores, feedback, estados vacíos y un popup modal. La TUI se ve pulida. Pero hay un problema sutil de rendimiento en el bucle de eventos, y una operación CRUD faltante que pondrá a prueba la arquitectura una última vez.

Este post cubre dos cosas: corregir el lag de entrada con polling no bloqueante, y añadir la funcionalidad de edición como un corte vertical que toca tres capas sin tocar el dominio. Cerramos con las conclusiones clave de toda la migración de CLI a TUI.

Polling de eventos no bloqueante: la corrección de rendimiento
#

Este cambio no tuvo impacto visual pero sí uno táctil inmediatamente perceptible.

El problema: lecturas bloqueantes
#

El bucle de eventos original usaba event::read(), que bloquea indefinidamente hasta que llega un evento de teclado:

draw → read (BLOCK until key pressed) → process → draw → read (BLOCK) → ...

Cada pulsación de tecla tenía que esperar un ciclo completo de dibujado antes de que se pudiera leer la siguiente. Al escribir rápido, esto creaba un lag perceptible: los caracteres aparecían en ráfagas en vez de suavemente. El bucle pasaba la mayor parte del tiempo bloqueado en read(), dibujando solo cuando un evento lo forzaba.

La corrección: poll con timeout
#

if event::poll(Duration::from_millis(16))? {
    if let Event::Key(key) = event::read()? {
        // process key
    }
}

event::poll(16ms) retorna inmediatamente si no hay evento listo. El bucle se convierte en:

draw → poll (return in ≤16ms) → process if event → draw → poll → ...

El bucle ahora corre a aproximadamente 60fps (1000ms / 16ms), redibujando la pantalla incluso cuando no se pulsan teclas. Esto significa que la pantalla siempre está fresca, y las teclas se procesan en el instante en que llegan. El timeout de 16ms es el estándar para aplicaciones TUI: suficientemente responsivo para escribir, suficientemente ligero en CPU (la capa de diff de terminal asegura que solo se envían los caracteres que cambiaron).

La diferencia se nota inmediatamente al escribir títulos de tareas. Los caracteres aparecen uno por uno, suavemente, en vez de en ráfagas.

Añadiendo edición: el test del corte vertical
#

La verdadera prueba de cualquier arquitectura no es la construcción inicial. Es lo fácil que resulta añadir funcionalidades después. Tenía un candidato perfecto: el dominio ya tenía un método edit_title() que estaba implementado, validado (rechaza títulos vacíos, actualiza modified_at, preserva created_at), y testeado. Simplemente nunca se había conectado a ninguna UI.

Añadir la funcionalidad de edición requirió tocar tres capas. Ninguna de ellas era el dominio.

Capa 1: Aplicación. edit_task.rs
#

Un nuevo caso de uso siguiendo exactamente el mismo patrón que los existentes:

pub struct EditTaskCommand {
    pub task_id: Uuid,
    pub new_title: String,
}

pub trait EditTaskUseCase {
    fn execute(&mut self, command: EditTaskCommand) -> ApplicationResult<Task>;
}

La orquestación del servicio es el patrón estándar buscar-transicionar-guardar: find_by_id para obtener la tarea, edit_title para aplicar la transición de dominio, save para persistir. El archivo era casi una copia de MarkTaskDoneService con diferentes llamadas a métodos.

Capa 2: Adaptador TUI
#

InputMode::Editing (app.rs): Una cuarta variante en el enum. El compilador inmediatamente marcó cada match sobre InputMode que necesitaba actualización. Este es el beneficio del match exhaustivo del Post 1: sin grep, sin esperanzas, el compilador te dice exactamente dónde.

Método start_editing(): Pre-rellena input_buffer con el título actual de la tarea para que el usuario pueda modificarlo en vez de reescribirlo desde cero. Esta es una decisión de UX que parece obvia pero es fácil de olvidar: si el usuario solo quiere corregir un typo, forzarle a reescribir todo el título es hostil.

handle_editing_mode() (event.rs): Una nueva función handler idéntica a handle_adding_mode en mapeo de teclas (Enter, Esc, Backspace, Char) pero llamando a edit_task() en la confirmación en vez de add_task(). Los modos Adding y Editing comparten comportamiento pero son modos separados porque disparan acciones diferentes en Enter. Comportamiento compartido sin identidad compartida.

Renderizado UI (ui.rs): El popup reutiliza la misma función render_input_popup, adaptando título y color según el modo. La barra de comandos del modo Normal ahora muestra [e]dit como nuevo atajo.

Capa 0: Dominio. Cero cambios
#

edit_title() ya estaba implementado. Ya validado. Ya manejando casos borde. El dominio estaba listo antes de que la funcionalidad se planificara. Este es el retorno hexagonal en su forma más tangible: el dominio anticipa funcionalidades porque modela el negocio, no la UI.

La evolución de la máquina de estados
#

              ┌──────────┐
    ┌────────▶│  Normal  │◀────────┐
    │         └─┬──┬──┬──┘         │
    │      [a]  │  │  │  [d]      │
    │      [e]  │  │  │            │
    │           ▼  │  ▼            │
    │    ┌───────┐ │ ┌──────────────┐
    ├────│Adding │ │ │ConfirmDelete │──┘
    │    └───────┘ │ └──────────────┘
    │   Esc/Enter  │    Esc/y/n
    │              ▼
    │       ┌─────────┐
    └───────│ Editing │
            └─────────┘
            Esc/Enter

De 3 estados a 4. Una variante de enum, unos cuantos match arms, y la funcionalidad entera funciona. Toda la implementación llevó unos 15 minutos. La mayor parte fue el boilerplate de la capa de aplicación.

Verificación
#

cargo test:   12 passed, 0 failed
cargo clippy: 0 warnings
cargo fmt:    clean

Los tests existentes siguen pasando. La funcionalidad de edición no rompió nada. Añadir una funcionalidad validó la arquitectura de la misma forma que la migración: sin ondas inesperadas, sin acoplamiento oculto, sin “espera, necesito cambiar el dominio también.”

Conclusiones clave
#

La arquitectura hexagonal demuestra su valor en el momento del intercambio
#

La sobrecarga de definir puertos, mantener capas separadas y resistir la tentación de poner lógica de dominio en adaptadores: todo se amortiza en el momento en que necesitas reemplazar una capa. La migración de CLI a TUI fue la prueba. Cero cambios en dominio, cero cambios en aplicación, cero cambios en puertos, cero cambios en persistencia. Doce tests pasando sin cambios. La arquitectura hizo exactamente lo que prometió.

Los enums de Rust son un DSL de máquina de estados
#

InputMode empezó con 3 variantes y creció a 4. Cada adición fue segura porque match es exhaustivo. El compilador es tu verificador de máquina de estados: no te dejará olvidar un caso. Si estás modelando modos mutuamente excluyentes y usando flags booleanos en vez de un enum, estás escribiendo bugs que el compilador podría haber cazado.

“¿Qué hace este clone realmente?” siempre vale la pena preguntarlo
#

La respuesta va desde “copia 50 bytes de PathBuf” hasta “duplica un pool de conexiones a base de datos.” Misma sintaxis, implicaciones radicalmente distintas. Cada vez que escribes .clone() en Rust, conoce qué hay dentro del struct. Para nuestro JsonFileTaskRepository, era una copia trivial. Para una implementación de repositorio diferente, podría ser una respuesta completamente distinta.

Los bucles de eventos no bloqueantes importan para TUIs
#

event::read() vs event::poll() es la diferencia entre escribir que se siente con lag y escribir que se siente instantáneo. Siempre haz poll con un timeout. 16ms es el estándar: renderizado a 60fps, procesamiento inmediato de teclas, mínimo overhead de CPU.

El último 20% de UX es el 80% de la experiencia
#

Codificación por colores, estados vacíos, feedback transitorio, popups modales: nada de esto cambia la funcionalidad. Pero transforma una herramienta que toleras en una que disfrutas usar. Las cinco mejoras de UX de este post fueron todas técnicamente simples. Su impacto en la experiencia fue desproporcionado. Nunca publiques una TUI sin hacer este pase.

Dónde estamos
#

La TUI está completa: añadir, editar, eliminar (con confirmación), alternar estado, filtrar por estado, navegación por teclado. Con codificación de colores, mensajes de feedback, popups modales y manejo de eventos responsivo. La arquitectura está limpia: el dominio no sabe nada de terminales, el adaptador TUI es autocontenido, la persistencia es intercambiable.

Hay un backlog de modos futuros que vale la pena explorar: un overlay de ayuda activado por ?, búsqueda y filtrado inline, una vista de detalle para información completa de tarea, opciones de ordenamiento por fecha o estado. Cada uno sería una nueva variante de InputMode y algo de código de renderizado. La base está lista. Gracias al match exhaustivo, el compilador guiaría cada adición.

El momento más satisfactorio de todo el proyecto? Ejecutar cargo test después de la migración y ver los 12 tests pasar sin cambios. Ese es el sonido de las fronteras arquitectónicas resistiendo bajo presión. El cangrejo construyó la cocina; la rata tomó el control del emplatado. Mismos ingredientes, mismas recetas, mismo dominio; pero la rata los sirve con color, con feedback, y con un popup que aparece exactamente donde tus ojos están mirando. La cocina está abierta. Bon appetit!

Código de referencia:

Todo TUI en Rust - Este artículo es parte de una serie.
Parte 5: Este artículo