Ir al contenido
  1. Posts/

Todo CLI en Rust 5. Siguiente paso, pasar de CLI a TUI con ratatui

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

Llegamos al cierre de la serie. Y cerramos con una pregunta que debería surgir de forma natural en cualquier herramienta de terminal que empieza a crecer: si el modelo de “un comando, una salida, adiós” empieza a friccionar, ¿cuál es el siguiente paso?

Para este proyecto, la respuesta es construir una TUI con ratatui. No como ejercicio cosmético, sino como evolución real de la interfaz.

todo-cli-en-rust-5-siguiente-paso-pasar-de-cli-a-tui-con-ratatui-img-55.png

Este post no es un tutorial de ratatui. Es una hoja de ruta técnica: qué conceptos nuevos aparecen, qué cambia en la arquitectura, y qué se reutiliza intacto.

Por qué una TUI y no quedarse en CLI
#

La CLI actual funciona bien para operaciones atómicas: add, list, done, delete. Pero cuando el flujo se vuelve iterativo (revisar la lista, marcar varias tareas, filtrar, volver a listar), el modelo comando-por-comando multiplica las rondas de ida y vuelta con el shell.

Una TUI resuelve esto con una sesión persistente. Pero el cambio no es solo técnico, es un cambio de modelo mental.

En la CLI, el usuario piensa en verbos: “quiero añadir una tarea”, “quiero listar las pendientes”. Cada intención es un comando con argumentos explícitos. El usuario declara qué quiere y se va.

En una TUI, el usuario piensa en estados y navegación: “estoy viendo mis tareas”, “me muevo a esta”, “la marco”. No hay un momento de “formular el comando”. La interacción es continua, espacial, directa. El feedback es inmediato y visual, ves el cambio en la misma pantalla donde tomaste la decisión.

Esto tiene consecuencias reales para la UX:

  • Descubribilidad: en una CLI, el --help es la documentación. En una TUI, los atajos tienen que ser visibles o, como mínimo, accesibles con una tecla de ayuda (?). Si el usuario tiene que adivinar, la TUI ha fracasado.
  • Feedback en contexto: en la CLI, un error es un mensaje en stderr que el usuario lee después del comando. En la TUI, el error aparece donde estás mirando, mientras sigues operando. Eso cambia cómo escribes los mensajes de error, ya no es Error: task not found (id: 3fa85f...), es una barra inferior que muestra Tarea no encontrada durante dos segundos y desaparece.
  • Flujo continuo vs discreto: en la CLI, cada operación es atómica e independiente. En la TUI, las operaciones son parte de una sesión. Esto significa que puedes (y debes) mantener contexto: si el usuario acaba de borrar una tarea, la selección debería moverse al siguiente elemento, no reiniciarse al principio.

No es “hacerlo bonito”. Es pasar de una interfaz de comandos a una interfaz de manipulación directa.

La despensa ya está llena: qué se reutiliza
#

Toda la inversión en separar capas desde el capítulo 1 paga aquí. El adaptador TUI es otro adaptador primario, exactamente igual que el CLI. Consume los mismos casos de uso, el mismo dominio, el mismo repositorio:

Cero cambios en estas capas. El TUI solo necesita un nuevo directorio bajo adapters/.

Convivencia: CLI y TUI en el mismo binario
#

El paso lógico no es reemplazar el binario CLI, sino añadir un subcomando:

todo-cli add "Comprar leche"    # CLI clásica, sigue funcionando
todo-cli tui                    # Abre la interfaz interactiva

En clap, esto es una variante más del enum TodoCommand:

#[derive(Debug, Clone, Subcommand)]
pub enum TodoCommand {
    Add { title: String },
    List { /* ... */ },
    Done { id: Uuid },
    // ...
    Tui,
}

Y en main.rs, un brazo más en el match:

TodoCommand::Tui => {
    tui::run(repository)?;
}

La CLI sigue disponible para scripts y automatización. La TUI es para uso interactivo. Dos menús en el mismo restaurante.

Ratatui en pinceladas: los conceptos que importan
#

No vamos a hacer un tutorial de ratatui, la documentación oficial es excelente. Pero sí hay conceptos que necesitas entender antes de diseñar el adaptador, porque afectan directamente a las decisiones de arquitectura.

Immediate mode rendering
#

A diferencia de un framework retained mode (donde creas widgets persistentes y modificas su estado), en ratatui redibujamos toda la interfaz cada frame:

loop {
    terminal.draw(|frame| {
        frame.render_widget(task_list_widget, area);
    })?;
    if let Event::Key(key) = event::read()? {
        // Actualiza estado
    }
}

No hay objetos widget persistentes. No hay binding bidireccional. Cada frame es una función del estado actual. Esto simplifica mucho el modelo mental: la UI es una proyección directa del estado, sin sincronización.

Widget y StatefulWidget
#

Ratatui tiene dos traits fundamentales. Widget es para elementos que no recuerdan nada entre frames (un Paragraph, un Block). StatefulWidget es para los que necesitan estado mutable durante el render, el ejemplo canónico es List, que actualiza el scroll position para mantener visible el elemento seleccionado:

frame.render_stateful_widget(
    task_list_widget,
    area,
    &mut app_state.list_state,  // &mut State, aquí empieza la fricción
);

Ese &mut en el render es el primer indicador de que el modelo de ownership va a cambiar respecto a la CLI. Volveremos a esto.

The Elm Architecture (TEA)
#

Ratatui no impone patrón de aplicación, pero la comunidad converge en The Elm Architecture: Model → Update → View. Un estado central, una función que lo transforma según mensajes, una función que lo proyecta en pantalla.

Encaja bien con nuestra mentalidad de dominio inmutable. El update puede disparar nuestros casos de uso existentes, la diferencia es que el resultado no se imprime en stdout, sino que actualiza el modelo para que el siguiente frame lo refleje.

Estructura del adaptador TUI
#

Siguiendo la misma convención que el adaptador CLI:

src/tasks/adapters/
  cli/
    cli_command.rs
    printer.rs
    errors.rs
  tui/
    app.rs            // Model + event loop principal
    events.rs         // Message: enum de eventos + mapeo de teclas
    renderer.rs       // View: función que dibuja la UI completa
    screens/
      task_list.rs    // Widget principal: tabla de tareas con selección

El mapeo es directo. events.rs es el equivalente TUI del parsing de clap: la frontera de entrada. renderer.rs es la función view, recibe el estado, divide el área con Layout, renderiza widgets. app.rs contiene el estado de la UI (selección, filtro, mensajes) y el loop principal que conecta eventos con casos de uso.

Qué cambia y qué no
#

No cambia: entidad Task, invariantes, casos de uso, repositorio JSON, taxonomía de errores por capa.

Sí cambia: aparece un event loop persistente, estado de UI mutable, y una gestión de errores que no puede matar la sesión.

Y aquí es donde la cosa se pone interesante para quien programa en Rust.

Lo que Rust te obliga a pensar
#

Esta sección es la que no vas a encontrar en el tutorial de ratatui. La librería te explica cómo dibujar widgets. Lo que no te explica es qué pasa cuando conectas esos widgets con un dominio que tiene su propio modelo de ownership. Y en Rust, eso no es opcional, es la primera pregunta que el compilador te va a hacer.

De stateless a &mut self: el cambio fundamental
#

La CLI es stateless por diseño: parsea argumentos, ejecuta un caso de uso, imprime resultado, sale. No hay estado que mantener entre invocaciones. Cada ejecución es una función pura del input.

La TUI invierte esto completamente. El event loop mantiene un AppState mutable que vive durante toda la sesión:

struct App {
    tasks: Vec<Task>,
    list_state: ListState,      // ratatui, necesita &mut en render
    filter: TaskFilter,
    status_message: Option<String>,
    repository: JsonFileTaskRepository,  // ← ¿quién es el dueño?
}

Ese repository dentro de App es la primera decisión no trivial. En la CLI, el repositorio se crea en main, se pasa por referencia mutable a la función que ejecuta el comando, y muere. En la TUI, el repositorio tiene que vivir mientras dure la sesión. Eso implica que App es su dueño.

La fricción del event loop: render vs mutación
#

El problema real aparece cuando el event loop necesita hacer dos cosas con el mismo estado:

  1. Renderizar: terminal.draw() necesita leer el estado para proyectarlo en pantalla.
  2. Mutar: el handler de eventos necesita &mut self para actualizar el estado tras una acción.

En un lenguaje con GC, esto no es un problema, lees y escribes cuando quieras. En Rust, el borrow checker entra en escena. No puedes tener &self (para render) y &mut self (para update) al mismo tiempo.

La solución más limpia es la que el propio patrón TEA sugiere: separar las fases. Primero renderizas (tomando &self del estado), después procesas eventos (tomando &mut self):

loop {
    // Fase 1: render, solo lectura
    terminal.draw(|frame| self.view(frame))?;

    // Fase 2: update, mutación exclusiva
    if let Event::Key(key) = event::read()? {
        self.update(key)?;
    }
}

Esto funciona porque las fases son secuenciales, nunca simultáneas. El borrow checker está satisfecho. Pero fíjate en lo que ha pasado: la arquitectura de tu event loop no la has decidido tú por elegancia, la ha decidido el compilador por correctitud.

¿Hace falta Arc<Mutex<>>?
#

La respuesta corta: no, si te mantienes en single-thread síncrono.

El event loop básico de ratatui es síncrono y single-threaded. El App vive en el stack del loop, nadie más lo toca. No necesitas Arc, no necesitas Mutex, no necesitas RefCell. El ownership es lineal y claro.

Pero si quisieras:

  • Lectura de eventos en un thread separado (para no bloquear el render con event::read()), ahora necesitas un mpsc::channel para enviar eventos al thread principal.
  • Operaciones de I/O asíncronas (por ejemplo, si el repositorio fuera remoto), necesitarías tokio, async/await, y probablemente Arc<Mutex<App>> para compartir estado entre el runtime async y el render loop.
  • Timers o polling periódico (para auto-refrescar la lista), de nuevo necesitas concurrencia.

Para una app de tareas locales con repositorio JSON en disco, nada de esto es necesario. Pero el mero hecho de que Rust te haga decidir explícitamente es parte del diseño. En Go o Python, tendrías un mutex implícito (el GIL) o goroutines con channels sin pensarlo. En Rust, la decisión es tuya y el compilador verifica que la hayas tomado correctamente.

El trait TaskRepository y &mut self
#

Hay un detalle más sutil. Nuestro trait TaskRepository usa &mut self para operaciones de escritura:

pub trait TaskRepository {
    fn save(&mut self, task: &Task) -> Result<(), RepoError>;
    fn find_all(&self) -> Result<Vec<Task>, RepoError>;
    fn delete(&mut self, id: &TaskId) -> Result<(), RepoError>;
}

En la CLI, esto no es problema: el repositorio se pasa como &mut repo al caso de uso, se ejecuta, fin. En la TUI, el repositorio vive dentro de App. Cuando el usuario pulsa d para marcar una tarea como done, el handler necesita &mut self.repository para llamar al caso de uso. Pero si en ese mismo instante estuvieras renderizando (hipotéticamente), tendrías un conflicto de borrows.

De nuevo, la separación secuencial render/update lo resuelve. Pero si algún día el repositorio fuera asíncrono y la operación de save tardara más de un frame, la historia cambia. Habría que sacar el repositorio del App, ponerlo detrás de un Arc<Mutex<>>, y manejar la respuesta como un Message asíncrono que llega al update en un frame posterior.

Esto es exactamente lo que la receta de async event handling de ratatui propone: un EventHandler en un thread dedicado con tokio, CancellationToken, y mpsc channels. Para nuestro caso es sobreingeniería. Pero es bueno saber dónde está la puerta de salida.

Retos de UX en el event loop
#

Más allá del ownership, hay decisiones de experiencia de usuario que la TUI obliga a tomar y que la CLI simplemente no tiene:

Errores que no matan la sesión. En la CLI, un CliError se propaga hasta main, se imprime y el proceso termina. En la TUI, un error al marcar una tarea como done tiene que mostrarse como mensaje temporal y dejar la sesión intacta. Esto implica que cada Result::Err dentro del event loop se traduce a un Message::Error(String) que actualiza la barra de estado, no a un ? que mata el proceso.

Estados modales. ¿Qué pasa cuando el usuario pulsa a para añadir una tarea? Necesitas un input de texto dentro de la TUI. Esto introduce un modo: el teclado deja de navegar la lista y pasa a escribir texto. Necesitas gestionar la transición entre modos (normal → input → confirmación), y cada modo tiene su propio mapeo de teclas. La CLI no tiene este problema porque cada comando es una invocación aislada con sus propios argumentos.

Confirmaciones destructivas. delete en la CLI se ejecuta y ya. En la TUI, probablemente quieras un prompt de confirmación (¿Borrar "Comprar leche"? [s/n]). Otro estado modal que hay que gestionar.

Responsive layout. La CLI no se adapta al ancho de la terminal, imprime lo que imprime. La TUI tiene que reaccionar a Event::Resize y redistribuir widgets. Si la terminal es demasiado estrecha para la tabla de tareas, necesitas truncar columnas o cambiar de layout. Ratatui facilita esto con Constraint y Layout, pero las decisiones de qué sacrificar son tuyas.

Conclusión. Dos chefs, la misma cocina
#

El cangrejo lleva cinco capítulos al mando de la cocina: diseñó la despensa, organizó los ingredientes por capas, montó el servicio de sala. Ahora le cede el pase al ratón. Misma cocina, mismos ingredientes, mismo contrato con el comedor, pero un chef distinto al frente del emplatado.

Eso es exactamente lo que la arquitectura hexagonal prometía desde el capítulo 1: cambiar quién emplata sin reescribir las recetas.

todo-cli-en-rust-5-siguiente-paso-pasar-de-cli-a-tui-con-ratatui-img-56.png

Pero el cambio de chef no es gratis. La CLI es un modelo sencillo, parse, execute, print, exit, que encaja como un guante en el ownership lineal de Rust. La TUI introduce un event loop persistente, estado mutable de larga vida, y decisiones de concurrencia que la CLI simplemente no tiene. El compilador te obliga a tomar esas decisiones explícitamente, y eso es a la vez la fricción y la garantía.

Lo que no cambia es lo que importa: el dominio sigue siendo inmutable, los casos de uso siguen teniendo la misma interfaz, el repositorio sigue leyendo y escribiendo JSON en ~/.local/share/. Lo único nuevo es quién está al frente de la ventanilla: el cangrejo sigue disponible para los pedidos rápidos (CLI), y el ratón se encarga del servicio de mesa (TUI).

Y esa, al final, era la apuesta de toda la serie.

El desarrollo real de esta evolución, ratatui tomando el mando de la cocina, lo contaré en una futura serie de posts. Esta serie cierra aquí, pero la cocina sigue abierta. ¡Bon appetit!

Todo CLI en Rust sin humo - Este artículo es parte de una serie.
Parte 6: Este artículo