Continuamos con la serie.
En el post anterior la rata aprendió a pintar: layout, tabla, barra de comandos, barra de estado. La pantalla se ve bien, pero nadie puede interactuar con ella todavía.
Ahora la rata conecta los oídos. Este post cubre la capa Event (mapeo de teclas a acciones), el ciclo de vida de la terminal (eso en lo que nadie piensa hasta que su terminal se queda atascada en raw mode), y el momento de la verdad de la migración: ejecutar cargo test y descubrir si la arquitectura hexagonal cumplió su promesa.
La capa Event: event.rs #
La capa Event conecta la entrada de teclado de crossterm con las mutaciones de estado de App. Cada InputMode tiene su propia función handler, porque la misma tecla significa cosas completamente distintas dependiendo del modo.
Handlers específicos por modo #
pub fn handle_events(app: &mut App) -> Result<(), TuiError> {
if event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press { return Ok(()); }
match app.input_mode {
InputMode::Normal => handle_normal_mode(app, key),
InputMode::Adding => handle_adding_mode(app, key),
InputMode::ConfirmDelete => handle_confirm_delete_mode(app, key),
}
}
}
Ok(())
}Tres funciones separadas. Cada una hace match sobre KeyCode y llama al método apropiado de App. Esta separación no es solo organizativa: hace cada handler más corto y asegura que las teclas solo se interpreten en su contexto correcto. Pulsar q en modo Normal cierra la app; pulsar q en modo Adding escribe la letra “q”. El despacho ocurre antes de interpretar la tecla, no después.
Guardas contra listas vacías #
En modo Normal, las acciones destructivas y dependientes de selección están protegidas:
fn handle_normal_mode(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('d') if !app.tasks.is_empty() => {
app.enter_confirm_delete();
}
KeyCode::Char('x') if !app.tasks.is_empty() => {
app.cycle_todo_done();
}
// ...
}
}La guarda if !app.tasks.is_empty() previene panics de índice fuera de rango cuando la lista de tareas está vacía. Sin ella, pulsar d en una lista vacía intentaría acceder a self.tasks[self.selected], lo que provoca un panic. La guarda es simple, pero es el tipo de cosa que te muerde durante las pruebas cuando olvidas poblar la lista primero.
El rediseño de teclas: toggle en vez de teclas separadas #
El plan original tenía d para “marcar como hecho” y t para “marcar como pendiente”. Durante la implementación, esto se sentía poco ergonómico: el usuario tiene que saber el estado actual de la tarea para elegir la tecla correcta. En su lugar, introduje x como un toggle (cycle_todo_done) que cambia automáticamente entre Done y Todo según el estado actual:
pub fn cycle_todo_done(&mut self) {
let status = self.tasks[self.selected].status();
match status {
TaskStatus::Done => self.mark_todo(),
TaskStatus::Todo => self.mark_done(),
}
}Una tecla en vez de dos. El usuario no necesita recordar el estado de la tarea: simplemente pulsa x y cambia. Esto liberó d para eliminar (entrar en modo ConfirmDelete), que es más convencional: d para eliminar, x para alternar.
Los métodos mark_done() y mark_todo() se volvieron privados. Son detalles de implementación detrás del toggle público. La API externa de App queda más limpia.
Backspace: la línea única #
KeyCode::Backspace => { app.input_buffer.pop(); }Si el buffer está vacío, pop() devuelve None y no pasa nada. Sin comprobación de límites, sin caso especial. El String::pop() de Rust lo maneja limpiamente.
El problema de restauración de terminal #
Este es un gotcha específico de aplicaciones TUI y fácil de pasar por alto hasta que ocurre.
Cuando ratatui::init() se ejecuta, hace tres cosas a la terminal:
- Activa raw mode: las pulsaciones de tecla se envían una por una en vez de con buffer de línea. Sin restaurar, la terminal no procesará Enter, flechas ni edición de línea correctamente después de que la app termine.
- Entra en pantalla alternativa: la TUI dibuja en un buffer separado para no sobrescribir el historial de scroll del usuario. Sin restaurar, el usuario queda atascado en la pantalla alternativa sin forma de ver sus comandos anteriores.
- Oculta el cursor: apariencia de TUI más limpia. Sin restaurar, el cursor permanece invisible después de salir.
Si el bucle principal sale vía ? (un error en handle_events, draw, o una operación del repositorio), la ejecución salta directamente fuera de la función y ratatui::restore() nunca se ejecuta. La terminal del usuario queda en raw mode sin cursor. Tendría que escribir reset a ciegas o cerrar la ventana de terminal.
Esto no es un escenario hipotético. Ocurre cada vez que hay un error de I/O inesperado, un archivo JSON malformado, o un error de dominio que burbujea sin capturar. Durante el desarrollo, ocurre con frecuencia.
La solución: capturar resultado, limpiar, retornar resultado #
Rust no tiene finally. Pero hay un patrón limpio:
fn run<R: TaskRepository + Clone>(repo: R) -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(repo)?;
let mut terminal = ratatui::init();
let mut table_state = TableState::default();
let result = run_loop(&mut terminal, &mut app, &mut table_state);
ratatui::restore(); // ALWAYS executes, error or not
result // THEN propagate the error if any
}La clave: ratatui::restore() se llama entre capturar el error y retornarlo:
run_loopse ejecuta y devuelveOk(())oErr(...)ratatui::restore()se ejecuta incondicionalmente- El
resultoriginal se retorna, propagando el error si lo hay
Este es el equivalente en Rust del defer de Go o el finally de Java. Una alternativa sería implementar Drop en un struct wrapper, pero eso es excesivo para una sola llamada de limpieza. El patrón “capturar, limpiar, retornar” es más simple y es el mismo enfoque que usan los ejemplos oficiales de ratatui.
Reconexión de main.rs #
El paso final es conectar la TUI al punto de entrada de la aplicación:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let repo = JsonFileTaskRepository::new()?;
tui::run(repo)?;
Ok(())
}Compara esto con el main.rs del CLI anterior, que parseaba argumentos de clap, hacía match sobre subcomandos, construía servicios, formateaba la salida y manejaba errores con un módulo de impresión. El main.rs de la TUI son dos líneas de código significativo. Toda la complejidad se ha movido al adaptador TUI en sí, donde pertenece.
El momento de la verdad de la migración #
Siete pasos completados. Dependencias actualizadas, módulos andamiados, estado diseñado, vista implementada, eventos conectados, main reconectado, ciclo de vida de terminal manejado. Hora de ver si algo se rompió.
$ cargo build --verbose
Compiling todo-cli v0.1.0
Finished `dev` profile
$ cargo test --verbose
running 12 tests
test ... ok (x12)
test result: ok. 12 passed; 0 failed
$ cargo clippy --all-targets --all-features -- -D warnings
Checking todo-cli v0.1.0
FinishedTodo verde. Y aquí viene la parte que valida la arquitectura:
Archivos que cambiaron en la migración #
| Archivo | Cambio |
|---|---|
Cargo.toml |
Añadidos ratatui + crossterm, eliminado clap |
src/main.rs |
Reconectado de despacho clap a bucle TUI |
src/tasks/adapters/mod.rs |
pub mod cli reemplazado por pub mod tui |
src/tasks/adapters/tui/mod.rs |
Nueva raíz de módulo |
src/tasks/adapters/tui/errors.rs |
Nuevo: enum TuiError |
src/tasks/adapters/tui/app.rs |
Nuevo: estado de App, InputMode, orquestación de casos de uso |
src/tasks/adapters/tui/ui.rs |
Nuevo: renderizado |
src/tasks/adapters/tui/event.rs |
Nuevo: manejo de eventos |
Archivos que NO cambiaron #
| Capa | Cambios |
|---|---|
src/tasks/domain/ |
0 |
src/tasks/application/ |
0 |
src/tasks/ports/ |
0 |
src/tasks/adapters/persistence/ |
0 |
| Los 12 tests existentes | 0 modificaciones, todos pasando |
Cero cambios en dominio, aplicación, puertos o persistencia. Los 12 tests existentes pasaron sin modificación. La arquitectura hexagonal hizo exactamente lo que prometía: toda la capa de presentación era un adaptador reemplazable, y reemplazarlo no tocó nada más.
El dominio no sabía ni le importaba si lo estaba conduciendo argumentos de clap o eventos de teclado de ratatui. Los casos de uso no sabían ni les importaba si sus resultados se imprimían en stdout o se renderizaban como filas de tabla. El repositorio no sabía ni le importaba si se llamaba una vez por proceso o cincuenta veces en una sesión.
Ese es el retorno hexagonal. La rata reconectó toda la parte frontal del restaurante sin que la cocina se enterara. Y fue el cargo test más satisfactorio que he ejecutado en este proyecto.
Correcciones de clippy por el camino #
Cuatro lints de clippy se detectaron y corrigieron durante el pase de verificación:
unit_argenselect_next/select_previous: envolverself.selected += 1dentro deOk()pasa()como argumento. Corregido moviendo la mutación antes delOk(())de retorno.useless_formatenui.rs:format!("{}", task.title())dondetask.title().to_string()es más claro y evita la sobrecarga del macro format.collapsible_ifenevent.rs:if let+ifanidados colapsados en una sola condición usando let-chains (&&), reduciendo la indentación. Usa la funcionalidad de let-chains de Rust, estable desde la versión 1.87.
Clippy detecta lo que hasta la rata más aguda pasa por alto. Ejecutarlo con -D warnings (denegar todas las advertencias) no es opcional en este proyecto.
Dónde estamos #
La TUI es funcional: puedes añadir tareas, listarlas, eliminarlas (con confirmación), alternar su estado, filtrar por estado y navegar con teclado. Todo alimentado por el mismo dominio, casos de uso y repositorio que usaba el CLI.
Pero “funcional” y “bueno” no son lo mismo. La interfaz actual es monocromática, los UUID siguen siendo prominentes, no hay feedback cuando las acciones tienen éxito, y el estado vacío es una pantalla en blanco incómoda. La rata ha construido la cocina. Ahora necesita aprender a emplatar. El próximo post trata sobre el pulido: colores, feedback transitorio, estados vacíos y un popup modal para entrada de texto.
Código de referencia: