Abrimos una nueva serie. La anterior recorrió la construcción de un gestor de tareas CLI en Rust con arquitectura hexagonal, desde el modelado del dominio hasta la persistencia JSON y el parseo de comandos con clap. El último capítulo cerró con una pregunta: si el modelo CLI empieza a friccionar, ¿qué viene después?
Esta serie es la respuesta. La rata toma el control de la cocina. Reemplazamos el adaptador CLI por una TUI interactiva construida con ratatui, y el camino empieza aquí: andamiaje, decisiones de diseño, y los primeros encuentros con el modelo de ownership de Rust en un event loop persistente.
El cambio de chef: del cangrejo a la rata #
La serie anterior terminó con una metáfora: el cangrejo (el CLI, impulsado por clap) le pasa el pase de cocina a la rata (la TUI, impulsada por ratatui). Misma cocina, mismos ingredientes, diferente emplatado.
Esa metáfora no era solo narrativa. Describe una operación arquitectónica real: intercambiar un adaptador sin tocar las capas de debajo. Si la arquitectura hexagonal ha estado haciendo su trabajo desde el capítulo 1, este intercambio debería ser quirúrgico. Cero cambios en el dominio. Cero en los casos de uso. Cero en los puertos. Solo un nuevo directorio bajo adapters/.
La rata husmea el cableado, encuentra el cable correcto, y roe exactamente una conexión. Veamos si el resto del circuito aguanta.
Qué cambia en el Cargo.toml #
El primer commit es aburrido, y ese es el punto. Eliminamos clap y añadimos dos dependencias nuevas:
[dependencies]
ratatui = "0.29"
crossterm = "0.28"
# clap eliminadoRatatui es el framework TUI: widgets, layouts, renderizado. Crossterm es el backend de terminal: modo raw, polling de eventos, pantalla alternativa. Trabajan juntos pero tienen responsabilidades distintas. Ratatui dibuja; crossterm habla con la terminal.
¿Por qué crossterm y no termion? Crossterm es multiplataforma (Windows, macOS, Linux) y es el backend por defecto de ratatui. No hay razón de peso para elegir otra cosa en un proyecto como este.
La eliminación de clap es significativa. El CLI anterior era un pub mod cli dentro de adapters/. Al eliminarlo, no estamos añadiendo una segunda interfaz junto al CLI. Lo estamos reemplazando por completo. El enfoque CLI de todo-cli add "Comprar leche" desaparece; la TUI toma el control como único adaptador primario.
Nota: El post anterior discutió la posibilidad de mantener CLI y TUI en el mismo binario mediante un subcomando
Tuien clap. Es un diseño válido. Para esta implementación, elegí el intercambio limpio: un adaptador a la vez, validando que las fronteras hexagonales aguantan bajo un reemplazo completo, no una coexistencia. Si quieres coexistencia, la técnica descrita en el post anterior (añadir una varianteTodoCommand::Tui) sigue aplicando.
Estructura de módulos: siguiendo la convención #
El adaptador CLI vivía bajo src/tasks/adapters/cli/ con tres ficheros: cli_command.rs (parseo), printer.rs (salida), errors.rs (tipos de error). La TUI sigue la misma lógica organizativa:
src/tasks/adapters/
tui/
mod.rs // Raíz del módulo, exports públicos
app.rs // Model: estado + lógica de actualización (TEA)
ui.rs // View: función de renderizado
event.rs // Manejo de teclas y mapeo de entrada
errors.rs // Tipos de error específicos de la TUIEl mapeo al patrón TEA (The Elm Architecture) es deliberado:
| Concepto TEA | Fichero | Responsabilidad |
|---|---|---|
| Model | app.rs |
Struct App con todo el estado de UI, métodos para mutarlo |
| View | ui.rs |
fn draw(app: &App, frame: &mut Frame) — renderizado puro |
| Update | event.rs |
Mapeo tecla-a-acción, delega en métodos de App |
Y errors.rs envuelve tanto ApplicationError como io::Error en un tipo TuiError, porque la TUI tiene que manejar fallos de I/O (caídas de terminal) que el CLI nunca enfrentó.
En adapters/mod.rs, el intercambio es una línea:
// pub mod cli; // eliminado
pub mod tui; // añadido
El resto del codebase ni se entera. Esto es la arquitectura hexagonal haciendo su trabajo de la forma más aburrida posible.
Hacer los estados inválidos irrepresentables: el enum InputMode #
Aquí es donde empieza el diseño. Una aplicación TUI necesita comportarse de forma diferente según lo que el usuario esté haciendo:
- Modo Normal: navegando la lista de tareas. Las teclas son atajos:
apara añadir,dpara borrar,xpara alternar estado,qpara salir. - Modo Adding: escribiendo el título de una nueva tarea. Las teclas son entrada de texto:
qescribe la letra “q”, no sale de la aplicación.Enterconfirma,Esccancela. - Modo ConfirmDelete: preguntando “¿Borrar esta tarea? (y/n)”. Solo se aceptan
y,nyEsc. Todo lo demás se ignora.
Estos modos son mutuamente excluyentes. La app está en exactamente un modo en cada momento. O navegas, o escribes, o confirmas. Nunca dos a la vez.
El enfoque tentador son flags booleanos (is_adding: bool, is_confirming_delete: bool). Dos booleanos crean cuatro combinaciones, pero solo tres son válidas. La cuarta, añadiendo y confirmando al mismo tiempo, es un sinsentido que el sistema de tipos acepta alegremente. Con n flags, tienes 2^n estados representables pero solo n + 1 válidos. La ratio se degrada exponencialmente.
La solución es un enum:
enum InputMode {
Normal,
Adding,
ConfirmDelete,
}Tres valores posibles. Uno por estado válido. La combinación inválida no puede existir en memoria. El compilador lo garantiza. Cuando más adelante añadí Editing como cuarta variante, cada match en el codebase señaló un brazo faltante. Sin grep, sin esperanzas: el compilador fue el verificador de la máquina de estados.
Este es el principio de Rust de hacer los estados inválidos irrepresentables. Escribí una serie dedicada (Hacer los estados invalidos irrepresentables 1. Por que los flags booleanos son bugs disfrazados) rastreando esta idea desde la charla original de Yaron Minsky en 2010 a través de la teoría de tipos de datos algebraicos, la correspondencia Curry-Howard, y ejemplos prácticos en Rust, Haskell, OCaml, TypeScript y Java. Si quieres la inmersión profunda en por qué esto funciona matemáticamente y cómo se conecta con los métodos formales, ese post lo cubre. Aquí nos centramos en cómo aplica a nuestra TUI.
Qué hace cada modo realmente #
Normal
#
El estado de reposo por defecto. El usuario ve la lista de tareas y puede:
- Navegar con
j/ko flechas - Pulsar atajos:
a(añadir),d(borrar),x(alternar hecho/pendiente),e(editar),f(ciclar filtro),q(salir) - Todos los eventos de teclado se interpretan como comandos, no como texto
Piensa en ello como el modo normal de Vim: cada tecla es un atajo.
Adding
#
El usuario ha pulsado a y está escribiendo el título de una nueva tarea:
- Las teclas son entrada de texto: letras, números, espacios se añaden a un búfer de entrada
Backspaceborra el último carácterEnterconfirma: crea la tarea y vuelve a NormalEsccancela: descarta la entrada y vuelve a Normal- Los atajos están desactivados: pulsar
qescribe “q”, no sale de la aplicación
Este modo existe porque las mismas teclas físicas tienen significados completamente diferentes. Sin él, no podrías escribir un título de tarea que contenga la letra “d” sin disparar una confirmación de borrado.
ConfirmDelete
#
El usuario ha pulsado d sobre una tarea seleccionada, y la app pregunta “¿Estás seguro? (y/n)”:
- Solo se aceptan
y,nyEsc yconfirma el borrado y vuelve a NormalnoEsccancela y vuelve a Normal- Todas las demás teclas se ignoran
- La navegación está desactivada: no puedes cambiar la selección durante la confirmación
Este modo existe como red de seguridad. El borrado elimina la tarea del fichero JSON permanentemente. Un paso de confirmación previene la pérdida accidental de datos por una pulsación errante.
La máquina de estados #
┌──────────┐
┌───▶│ Normal │◀───┐
│ └────┬─┬───┘ │
│ [a] │ │ [d] │
│ ▼ ▼ │
│ ┌───────┐ ┌──────────────┐
└──│Adding │ │ConfirmDelete │──┘
└───────┘ └──────────────┘
Esc/Enter Esc/y/nTres estados, cuatro transiciones. Cada transición es una sola pulsación de tecla. Cada estado tiene una “escotilla de escape” clara de vuelta a Normal. Este es el patrón mínimo viable de interacción para una TUI que soporta creación y acciones destructivas.
El puzzle de ownership: ¿qué hace clone() realmente aquí? #
Con el struct App conteniendo un TaskRepository, nos topamos con una pregunta específica de Rust que no existiría en lenguajes con recolector de basura.
Los servicios de casos de uso toman ownership del repositorio:
impl<R: TaskRepository> AddTaskService<R> {
pub fn new(repo: R) -> Self { Self { repo } }
}En el CLI, cada servicio se creaba una vez, se ejecutaba una vez, y el proceso terminaba. En la TUI, el App vive durante toda la sesión. El usuario puede añadir una tarea, luego listar, luego borrar, luego añadir otra. Si movemos el repo a un servicio, el App lo pierde después de la primera operación.
La solución: self.repo.clone(). Pero nuestro JsonFileTaskRepository es simplemente un wrapper alrededor de un PathBuf, unos 50 bytes. Clonarlo es ruido comparado con el I/O que el servicio hace después (leer fichero, parsear JSON, serializar, escribir). Las alternativas (préstamos &mut, Rc<RefCell<>>, cambiar las firmas de los puertos) añaden complejidad por cero beneficio en un struct tan ligero.
Escribí una serie dedicada sobre estrategias de ownership en Rust que cubre este tema en profundidad: qué hace clone para distintos tipos (desde copias triviales de PathBuf hasta duplicaciones catastróficas de búferes de varios megabytes), las seis estrategias que Rust ofrece para compartir estado (move, borrow, clone, Rc/Arc, mutabilidad interior, Cow), y por qué la “culpa del clone” instintiva de la comunidad Rust suele estar fuera de lugar. Si quieres la visión completa de cuándo clonar y cuándo no, esa serie lo cubre.
Dónde estamos #
Tenemos el andamiaje montado:
- Dependencias: ratatui + crossterm, clap eliminado
- Estructura de módulos:
adapters/tui/conapp.rs,ui.rs,event.rs,errors.rs - Máquina de estados: enum
InputModecon tres variantes y reglas de transición claras - Estrategia de ownership: clonar el repositorio para cada invocación de caso de uso, justificado por la naturaleza ligera del struct
Ni un solo píxel en pantalla todavía. Ni event loop corriendo. Es intencional. Este post iba de decisiones, no de renderizado. La rata ha estudiado el plano, ha mapeado cada túnel y ha planificado la ruta. El trabajo de diseño que ocurre antes del primer frame.render_widget() determina si la implementación será limpia o caótica.
En el siguiente post, la rata empieza a construir: la capa de renderizado que proyecta el estado en la terminal.
Código de referencia: