Interfaces de terminal en Go: de fmt a Bubble Tea

Cuando construyes herramientas de línea de comandos, la gestión de la salida es la diferencia entre una utilidad profesional y un script ruidoso. No se trata solo de imprimir texto; se trata de gestionar flujos de datos y estados visuales.

Para entender esto, debemos separar los flujos. Todo programa debería usar os.Stdout para los datos que el usuario quiere procesar (el “resultado” de la herramienta) y os.Stderr para logs, errores o estados de progreso. Si un usuario ejecuta mi-herramienta > salida.txt, solo quiere los datos en el archivo, no el mensaje de “Cargando…” o un error. Para decidir si debes mostrar colores o barras de progreso, debes detectar si la salida es una TTY (una terminal real) o un pipe/archivo; de lo contrario, enviarás secuencias de escape ANSI (códigos de colores) que ensuciarán los archivos de log.

Si el requerimiento es solo texto con algo de estilo, usamos lipgloss para manejar estilos (colores, bordes, padding) mediante secuencias ANSI de forma declarativa. Pero cuando la herramienta requiere interactividad —como un menú, un buscador o un dashboard que se actualiza en tiempo real— entramos en el terreno de las TUI (Terminal User Interfaces) mediante el patrón Model-View-Update (MVU) de bubbletea. Este patrón separa el estado de la aplicación (Model), la lógica de eventos (Update) y la representación visual (View), permitiendo que la interfaz reaccione a eventos del teclado o temporizadores sin corromper la terminal. Para componentes complejos (como un spinner o una barra de progreso), usamos bubbles, que son componentes pre-construidos para integrarse en nuestro bucle de Bubble Tea.

Si intentas gestionar el estado de una interfaz interactiva usando solo fmt.Println y variables globales, terminarás con una terminal rota o con parpadeos constantes. Por eso, la arquitectura de mensajería de Bubble Tea es esencial para aplicaciones complejas.

package main

import (
	"fmt"
	"os"
	"time"

	"github.com/charmbracelet/bubbles/progress"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

// Estilos de lipgloss definidos fuera para reutilización
var (
	titleStyle = lipgloss.NewStyle().
			Bold(true).
			Foreground(lipgloss.Color("205")).
			Underline()

	statusStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color("10")).
			Italic(true)

	errorStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color("9")).
			Bold(true)
)

// model define el estado de nuestra TUI
type model struct {
	progress  float64
	percent   int
	ready     bool
	progressW progress.Model
}

func (m model) Init() tea.Cmd {
	// Iniciamos un comando que genera ticks de tiempo para la animación
	return tick()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		// Si el usuario presiona 'q' o 'Ctrl+C', terminamos
		if msg.String() == "q" || msg.String() == "ctrl+c" {
			return m, tea.Quit
		}
	case tickMsg:
		// Actualizamos el estado según el mensaje de tiempo
		if m.percent >= 100 {
			m.ready = true
			return m, tea.Quit
		}
		m.percent += 5
		m.progress = float64(m.percent) / 100
		return m, tick()
	}
	return m, nil
}

func (m model) View() string {
	// Construcción de la vista usando lipgloss para el layout
	s := titleStyle.Render("🚀 Desplegando infraestructura...") + "\n\n"
	s += fmt.Sprintf("Estado: %s\n\n", statusStyle.Render("Procesando paquetes..."))
	
	// Renderizado de la barra de progreso de 'bubbles'
	s += fmt.Sprintf("%s %d%%\n\n", m.progressW.ViewAsProgress(), m.percent)
	
	if m.ready {
		s += errorStyle.Render("¡Despliegue completado con éxito!")
	} else {
		s += "Presiona 'q' para cancelar"
	}

	return s
}

// Mensajes personalizados para el loop de eventos
type tickMsg time.Time

func tick() tea.Cmd {
	return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg {
		return tickMsg(t)
	})
}

func main() {
	// 1. Output básico: Información inicial en Stderr (limpio para pipes)
	fmt.Fprintf(os.Stderr, "[INFO] Iniciando proceso de despliegue...\n")

	// 2. Preparación de la TUI
	// Creamos el modelo inicial
	p := progress.New(progress.Config{
		Width:         40,
		Height:        2,
		Color:         lipgloss.Color("12"),
		Bar:           progress.UltaBar{},
		PulseColor:    lipgloss.Color("39"),
	})

	m := model{
		progressW: p,
	}

	// 3. Ejecución del programa Bubble Tea
	// tea.NewProgram gestiona el ciclo de vida de la terminal
	p_tea := tea.NewProgram(m)

	if _, err := p_tea.Run(); err != nil {
		fmt.Fprintf(os.Stderr, errorStyle.String("Error crítico: %v\n", err))
		os.Exit(1)
	}

	// 4. Output final estilizado con lipgloss
	fmt.Println(titleStyle.Render("✔ Tarea finalizada"))
	fmt.Fprintf(os.Stdout, "Resumen: El despliegue se completó correctamente.\n")
}

Desglose del funcionamiento

  • Separación de flujos en main: Observa cómo usamos fmt.Fprintf(os.Stderr, ...) para el log de inicio. Si alguien redirige la salida de este binario a un archivo, los mensajes de log no contaminarán ese archivo.
  • El Ciclo de Vida (MVU):
    • Init(): Devuelve un tea.Cmd (un comando asíncrono). Aquí usamos tick() para lanzar una secuencia de mensajes de tiempo que dispararán las actualizaciones.
    • Update(): Es el corazón de la lógica. Recibe un mensaje (tea.Msg) y devuelve un nuevo estado. Es un “state machine” puro: no modificas el estado directamente de forma descontrolada, sino que retornas una copia actualizada.
    • View(): Es una función pura que transforma el estado actual en una string. Fíjate cómo usamos m.progressW.ViewAsProgress(): estamos delegando la renderización de la barra de progreso al componente de bubbles.
  • Estilizado con lipgloss: En lugar de insertar códigos ANSI manualmente con \033[31m, definimos estilos como titleStyle. Esto hace que el código sea mantenible y permite cambiar el tema de toda la CLI en un solo lugar.
  • Gestión de la terminal: tea.NewProgram(m) es quien realmente se encarga de entrar en “modo alternativo” de la terminal para que, al terminar el programa, la pantalla vuelva a su estado original sin dejar el texto de la barra de progreso flotando en la terminal del usuario.

El error frecuente

Un error clásico cuando empiezas a usar bubbletea es intentar usar fmt.Println() dentro de la función Update() o View() para debugear.

// ERROR: Esto corromperá la interfaz
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if msg != nil {
        fmt.Println("Debug:", msg) // ¡ESTO ROMPERÁ LA UI!
    }
    return m, nil
}

Por qué falla: bubbletea tiene el control absoluto de la posición del cursor y el buffer de la terminal. Cuando haces un fmt.Println, escribes directamente en os.Stdout fuera del control del motor de renderizado, lo que causa que el texto aparezca en medio de la interfaz, rompa el layout o deje basura visual. Para debugear en TUI, usa tea.Log() o envía mensajes de error a os.Stderr.

206

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio