Ir al contenido
  1. Posts/

Todo TUI en Rust 2. Renderizando la capa View con ratatui

·5 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 2: Este artículo

Continuamos con la serie.

En el post anterior la rata mapeó los túneles: dependencias, estructura de módulos, máquina de estados, estrategia de ownership. Todo diseño, cero píxeles.

Ahora la rata coge el pincel. Este post cubre la capa View: la función que toma el estado de la aplicación y lo pinta en la terminal. En el patrón TEA, este es el paso de renderizado puro: leer &App, dibujar widgets, no mutar nada.

La capa View: ui.rs
#

Ratatui usa renderizado en modo inmediato: en cada frame, describes la pantalla entera desde cero. No hay árbol de widgets retenido, ni DOM virtual, ni diffing. Llamas a terminal.draw(|frame| ...) y pintas widgets en áreas rectangulares. Cuando llega el siguiente frame, pintas otra vez desde cero.

Suena desperdiciado. No lo es. Ratatui y crossterm gestionan el diffing de terminal internamente, enviando solo los caracteres que realmente cambiaron. Desde la perspectiva de la rata, el modelo mental es simple: la UI es una función pura del estado. Lo que draw() pinta es la verdad, derivada enteramente de App.

La capa View es una única función pública:

pub fn draw(app: &App, frame: &mut Frame) {
    // Layout, table, command bar, status bar
}

Fíjate en la firma: &App, no &mut App. La View lee estado pero nunca lo muta. Este es el contrato TEA: el renderizado es una proyección, no un efecto secundario.

Layout de tres zonas
#

La pantalla se divide en tres zonas verticales usando Layout::vertical:

let chunks = Layout::vertical([
    Constraint::Fill(1),     // Main table: takes all remaining space
    Constraint::Length(1),   // Command bar: exactly 1 row
    Constraint::Length(1),   // Status bar: exactly 1 row
])
.split(frame.area());

La tabla obtiene todo el espacio que necesita mediante Fill(1). Las dos filas inferiores son cromo fijo: la barra de comandos muestra los atajos disponibles, y la barra de estado muestra el filtro activo y el conteo de tareas. Limpio y predecible. Sin importar cómo se redimensione la terminal, las proporciones se mantienen sensatas.

La tabla de tareas
#

La tabla es el núcleo de la interfaz. Usa render_stateful_widget con un TableState para el resaltado de filas:

frame.render_stateful_widget(table, chunks[0], table_state);

Ese table_state es la razón por la que necesitamos &mut TableState durante el renderizado, aunque dijimos que la View no muta App. La distinción importa: TableState es estado de renderizado (posición de scroll, resaltado de fila seleccionada), no estado de aplicación (tareas, filtro, modo). El trait StatefulWidget de ratatui requiere &mut State porque necesita actualizar el offset de scroll para mantener la fila seleccionada visible. Este es un detalle de implementación de la capa de renderizado, no una fuga de mutación hacia el modelo de aplicación.

Las columnas son:

Columna Ancho Contenido
ID 8 chars UUID truncado a los primeros 8 caracteres hexadecimales
Estado 10 chars [ ] TODO o [x] DONE
Título Fill restante Título de la tarea, ocupa todo el espacio disponible
Creado 12 chars Formato Mar 21 14:30
Modificado 12 chars Formato Mar 21 14:30

El truncado de UUID merece una nota. Un UUID v4 completo tiene 36 caracteres: a1b2c3d4-e5f6-7890-abcd-ef1234567890. Es espacio de pantalla que no nos podemos permitir en una terminal. Los primeros 8 caracteres hexadecimales (a1b2c3d4) dan 4 mil millones de combinaciones, más que suficiente unicidad para una lista de tareas personal. Los timestamps usan format("%b %d %H:%M") de chrono: compacto, legible y sin zona horaria (el contexto local del usuario está implícito).

La fila seleccionada se resalta con ">> " como símbolo de highlight. Un toque pequeño que hace que la navegación por teclado se sienta inmediata: ves exactamente dónde estás en todo momento.

La barra de comandos: ayuda sensible al contexto
#

La barra de comandos es un Paragraph cuyo contenido cambia según InputMode:

fn render_command(app: &App) -> Line<'static> {
    match app.input_mode {
        InputMode::Normal => Line::from(vec![
            Span::styled("[a]", Style::new().cyan()),
            Span::raw("dd "),
            Span::styled("[d]", Style::new().red()),
            Span::raw("el "),
            Span::styled("[x]", Style::new().yellow()),
            Span::raw("done/todo "),
            // ...
        ]),
        InputMode::Adding => Line::from(vec![
            Span::raw("New: "),
            Span::raw(&app.input_buffer),
            Span::styled("█", Style::new().cyan()),
            Span::raw("  [Enter] confirm [Esc] cancel"),
        ]),
        InputMode::ConfirmDelete => Line::from(vec![
            Span::styled("Delete? ", Style::new().red().bold()),
            Span::raw("[y]es [n]o"),
        ]),
    }
}

Cada match arm devuelve un Line directamente. Esto evita un problema sutil de lifetimes: si crearas un String local y devolvieras un &str de él, el borrow no sobreviviría a la función. Al construir Span::raw y Span::styled inline, todo es 'static y el lifetime checker queda satisfecho.

El diseño de la barra de comandos sigue un principio de la serie CLI anterior: descubribilidad. En un CLI, --help es la documentación. En una TUI, los atajos deben ser visibles sin preguntar. La barra de comandos cambia con cada modo, mostrando solo las teclas que son relevantes justo ahora. El usuario nunca tiene que adivinar.

La barra de estado
#

let status = format!("Filter: {} | {} tasks", app.filter, app.tasks.len());

Simple, informativa, siempre visible. La etiqueta del filtro viene de la implementación de Display de TaskStatusFilter, lo que significa que agregar una nueva variante de filtro en el futuro se mostrará automáticamente aquí sin tocar ui.rs.

El gotcha de Block
#

Un detalle sutil me costó unos minutos: el Block de la tabla (el borde con el título “TODO Tasks”) debe pasarse al Table mediante .block(), no renderizarse por separado:

// Correct: Block belongs to the Table
let table = Table::new(rows, widths)
    .block(Block::bordered().title("TODO Tasks"))
    .highlight_symbol(">> ");

// Wrong: rendering them independently makes them fight for the same area
frame.render_widget(block, area);
frame.render_widget(table, area);  // ← overlaps the border

Cuando renderizas un Block y un Table independientemente en el mismo Rect, el contenido de la tabla sobrescribe el borde. El block debe ser propiedad de la tabla para que ratatui calcule el área interior correctamente, renderizando el contenido de la tabla dentro del borde, no encima de él.

Dónde estamos
#

La rata sabe pintar. La tabla se renderiza, la barra de comandos se adapta al modo actual, la barra de estado muestra filtro y conteo. Pero pintar sin oídos no tiene sentido: la TUI dibuja una pantalla bonita con la que nadie puede interactuar.

En el próximo post, la rata conecta los oídos: manejo de eventos, despacho de teclas por modo, la red de seguridad de restauración de terminal, y el momento de la verdad de la migración donde cargo test nos dice si la promesa hexagonal se cumplió.

Código de referencia:

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