feat: add Go TUI rendering and terminal helper functions

7 funciones Go del dominio tui: apply_gradient (gradiente de color en texto),
draw_box y draw_separator (renderizado de cajas y separadores con box_chars),
load_ascii_art (carga de arte ASCII desde archivos), normalize_terminal_output
y strip_ansi (limpieza de salida de terminal), read_dir_autocomplete
(autocompletado de rutas de directorio). Incluye box_chars.go como helper
de caracteres Unicode para bordes.
This commit is contained in:
2026-04-12 13:54:54 +02:00
parent 6d73e1b4be
commit 8bc721d53b
15 changed files with 541 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// DefaultGradientColors is the default color palette for ASCII art gradients.
// Purple → blue → cyan → red, applied line by line.
var DefaultGradientColors = []lipgloss.Color{
lipgloss.Color("#9b59b6"),
lipgloss.Color("#8e44ad"),
lipgloss.Color("#3498db"),
lipgloss.Color("#2980b9"),
lipgloss.Color("#1abc9c"),
lipgloss.Color("#16a085"),
lipgloss.Color("#e74c3c"),
lipgloss.Color("#c0392b"),
}
// ApplyGradient applies a color gradient to ASCII art lines.
// Each line gets a color from the palette, distributed proportionally.
// Pass nil for colors to use DefaultGradientColors.
func ApplyGradient(lines []string, colors []lipgloss.Color) string {
if len(lines) == 0 {
return ""
}
if len(colors) == 0 {
colors = DefaultGradientColors
}
var result strings.Builder
totalLines := len(lines)
for i, line := range lines {
colorIndex := (i * len(colors)) / totalLines
if colorIndex >= len(colors) {
colorIndex = len(colors) - 1
}
styledLine := lipgloss.NewStyle().Foreground(colors[colorIndex]).Render(line)
result.WriteString(styledLine)
result.WriteString("\n")
}
result.WriteString("\n")
return result.String()
}
+56
View File
@@ -0,0 +1,56 @@
---
name: apply_gradient
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func ApplyGradient(lines []string, colors []lipgloss.Color) string"
description: "Aplica un degradado de colores linea por linea a texto ASCII art usando lipgloss. Distribuye los colores de la paleta proporcionalmente entre las lineas."
tags: [tui, ascii, gradient, color, lipgloss, art, header]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [strings, github.com/charmbracelet/lipgloss]
params:
- name: lines
desc: "lineas de ASCII art a colorear"
- name: colors
desc: "paleta de colores lipgloss; nil usa DefaultGradientColors (purple->blue->cyan->red)"
output: "string con las lineas coloreadas concatenadas con newlines"
tested: false
tests: []
test_file_path: ""
file_path: "functions/tui/apply_gradient.go"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "launcher/core/gradient.go"
---
## Ejemplo
```go
lines := []string{
" ██████╗ ███████╗",
" ██╔══██╗██╔════╝",
" ██║ ██║█████╗ ",
}
// Con paleta por defecto
fmt.Print(tui.ApplyGradient(lines, nil))
// Con paleta custom
palette := []lipgloss.Color{
lipgloss.Color("#ff0000"),
lipgloss.Color("#00ff00"),
lipgloss.Color("#0000ff"),
}
fmt.Print(tui.ApplyGradient(lines, palette))
```
## Notas
Cada linea recibe un color de la paleta distribuido proporcionalmente: `colorIndex = (i * len(colors)) / totalLines`. Para ASCII art con pocas lineas, usar una paleta corta. Para muchas lineas, una paleta larga da transiciones mas suaves.
`DefaultGradientColors` es una variable publica con 8 colores (purple->blue->cyan->red) que se puede reasignar si se desea cambiar el default global.
+14
View File
@@ -0,0 +1,14 @@
package tui
// Box drawing characters for TUI borders and separators.
const (
BoxTL = "╔"
BoxTR = "╗"
BoxBL = "╚"
BoxBR = "╝"
BoxH = "═"
BoxV = "║"
BoxML = "╠"
BoxMR = "╣"
BoxSep = "─"
)
+14
View File
@@ -0,0 +1,14 @@
package tui
import "github.com/charmbracelet/lipgloss"
// DrawBox draws a Unicode box around content with the given width and style.
func DrawBox(content string, width int, style lipgloss.Style) string {
fill := lipgloss.NewStyle().Render(
lipgloss.PlaceHorizontal(width-2, lipgloss.Left, BoxH, lipgloss.WithWhitespaceChars(BoxH)),
)
topLine := style.Render(BoxTL + fill + BoxTR)
bottomLine := style.Render(BoxBL + fill + BoxBR)
return topLine + "\n" + content + "\n" + bottomLine
}
+47
View File
@@ -0,0 +1,47 @@
---
name: draw_box
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func DrawBox(content string, width int, style lipgloss.Style) string"
description: "Dibuja un box unicode (bordes dobles) alrededor de contenido con ancho y estilo lipgloss configurables."
tags: [tui, box, border, unicode, draw, lipgloss]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [github.com/charmbracelet/lipgloss]
params:
- name: content
desc: "texto interior del box"
- name: width
desc: "ancho total del box en caracteres"
- name: style
desc: "estilo lipgloss para los bordes"
output: "string con el box renderizado (top + content + bottom)"
tested: false
tests: []
test_file_path: ""
file_path: "functions/tui/draw_box.go"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "launcher/ui/styles.go"
---
## Ejemplo
```go
style := lipgloss.NewStyle().Foreground(lipgloss.Color("#00d7ff"))
box := tui.DrawBox(" Hello World ", 40, style)
fmt.Println(box)
// ╔══════════════════════════════════════╗
// Hello World
// ╚══════════════════════════════════════╝
```
## Notas
Usa caracteres de box drawing doble (BoxTL=╔, BoxH=═, etc.) definidos en box_chars.go. El contenido no se recorta ni centra automaticamente — el caller controla el formato interior.
+13
View File
@@ -0,0 +1,13 @@
package tui
import "github.com/charmbracelet/lipgloss"
// DrawSeparator draws a horizontal separator line of the given width.
func DrawSeparator(width int, style lipgloss.Style, sepChar string) string {
if sepChar == "" {
sepChar = BoxSep
}
return style.Render(
lipgloss.PlaceHorizontal(width, lipgloss.Left, sepChar, lipgloss.WithWhitespaceChars(sepChar)),
)
}
+48
View File
@@ -0,0 +1,48 @@
---
name: draw_separator
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func DrawSeparator(width int, style lipgloss.Style, sepChar string) string"
description: "Dibuja una linea separadora horizontal con ancho, estilo y caracter configurables. Por defecto usa el caracter de separacion simple (─)."
tags: [tui, separator, line, draw, lipgloss, divider]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [github.com/charmbracelet/lipgloss]
params:
- name: width
desc: "ancho de la linea en caracteres"
- name: style
desc: "estilo lipgloss para el separador"
- name: sepChar
desc: "caracter de separacion; vacio usa BoxSep (─)"
output: "string con la linea separadora renderizada"
tested: false
tests: []
test_file_path: ""
file_path: "functions/tui/draw_separator.go"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "launcher/ui/styles.go"
---
## Ejemplo
```go
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#6c6c6c"))
sep := tui.DrawSeparator(60, dim, "")
fmt.Println(sep)
// ────────────────────────────────────────────────────────────
// Con caracter custom
sep2 := tui.DrawSeparator(40, dim, "═")
```
## Notas
Si `sepChar` es vacio, usa `BoxSep` (─) de box_chars.go. Para lineas dobles usar "═", para puntos "·", etc.
+52
View File
@@ -0,0 +1,52 @@
package tui
import (
"bufio"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
// LoadASCIIArt reads a random .txt file from staticPath and returns it
// rendered with a color gradient. Pass nil for colors to use DefaultGradientColors.
// Returns "" if no .txt files are found or on any error.
func LoadASCIIArt(staticPath string, colors []lipgloss.Color) string {
files, err := os.ReadDir(staticPath)
if err != nil {
return ""
}
var txtFiles []string
for _, f := range files {
if !f.IsDir() && strings.HasSuffix(f.Name(), ".txt") {
txtFiles = append(txtFiles, f.Name())
}
}
if len(txtFiles) == 0 {
return ""
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
selected := txtFiles[rng.Intn(len(txtFiles))]
file, err := os.Open(filepath.Join(staticPath, selected))
if err != nil {
return ""
}
defer file.Close()
var lines []string
sc := bufio.NewScanner(file)
for sc.Scan() {
lines = append(lines, sc.Text())
}
if len(lines) == 0 {
return ""
}
return ApplyGradient(lines, colors)
}
+54
View File
@@ -0,0 +1,54 @@
---
name: load_ascii_art
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: impure
signature: "func LoadASCIIArt(staticPath string, colors []lipgloss.Color) string"
description: "Lee un archivo .txt aleatorio de un directorio y lo renderiza con degradado de colores. Util para headers de TUIs con ASCII art variado."
tags: [tui, ascii, art, header, gradient, random, file]
uses_functions: [apply_gradient_go_tui]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [bufio, math/rand, os, path/filepath, strings, time, github.com/charmbracelet/lipgloss]
params:
- name: staticPath
desc: "directorio con archivos .txt de ASCII art"
- name: colors
desc: "paleta de colores para el degradado; nil usa DefaultGradientColors"
output: "string con ASCII art coloreado, o vacio si no hay archivos o hay error"
tested: false
tests: []
test_file_path: ""
file_path: "functions/tui/load_ascii_art.go"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "launcher/middleware/assets.go"
---
## Ejemplo
```go
// Cada app pone sus archivos .txt en un directorio static/
header := tui.LoadASCIIArt("./static", nil)
if header != "" {
fmt.Print(header)
}
// Con paleta custom
palette := []lipgloss.Color{
lipgloss.Color("#ff6b6b"),
lipgloss.Color("#feca57"),
lipgloss.Color("#48dbfb"),
}
header = tui.LoadASCIIArt("./static", palette)
```
## Notas
Patron de uso: cada app TUI mantiene un directorio `static/` con archivos `.txt` de ASCII art. Al iniciar, `LoadASCIIArt` elige uno al azar y le aplica el degradado de colores. Esto da variedad visual en cada ejecucion.
Fallback silencioso: si el directorio no existe, no tiene .txt, o hay error de lectura, retorna "" sin error. El caller decide si mostrar un fallback.
@@ -0,0 +1,28 @@
package tui
import "strings"
// NormalizeTerminalOutput strips ANSI codes, normalizes line endings,
// and removes non-printable control characters from terminal output.
func NormalizeTerminalOutput(s string) string {
if s == "" {
return ""
}
clean := StripANSI(s)
clean = strings.ReplaceAll(clean, "\r\n", "\n")
clean = strings.ReplaceAll(clean, "\r", "\n")
var b strings.Builder
b.Grow(len(clean))
for _, r := range clean {
switch {
case r == '\n' || r == '\t':
b.WriteRune(r)
case r >= 32 && r != 127:
b.WriteRune(r)
}
}
return b.String()
}
@@ -0,0 +1,40 @@
---
name: normalize_terminal_output
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func NormalizeTerminalOutput(s string) string"
description: "Limpia output de terminal: remueve codigos ANSI, normaliza saltos de linea (CRLF/CR a LF) y elimina caracteres de control no imprimibles. Preserva tabs y newlines."
tags: [tui, terminal, normalize, clean, output, ansi]
uses_functions: [strip_ansi_go_tui]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [strings]
params:
- name: s
desc: "output de terminal con posibles codigos ANSI y caracteres de control"
output: "string limpio con solo caracteres imprimibles, tabs y newlines"
tested: false
tests: []
test_file_path: ""
file_path: "functions/tui/normalize_terminal_output.go"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "launcher/core/commands.go"
---
## Ejemplo
```go
raw := "\033[32mOK\033[0m\r\nLinea 2\x00oculto"
clean := tui.NormalizeTerminalOutput(raw)
// clean == "OK\nLinea 2oculto"
```
## Notas
Preserva `\n` y `\t` como caracteres validos. Filtra todo caracter con valor < 32 (excepto tab y newline) y DEL (127). Util para capturar output de subprocesos y mostrarlo en TUIs donde los codigos ANSI rompen el render.
+32
View File
@@ -0,0 +1,32 @@
package tui
import (
"os"
"sort"
"strings"
)
// AutocompleteCandidate represents a directory entry for path autocomplete.
type AutocompleteCandidate struct {
Name string
IsDir bool
}
// ReadDirAutocomplete reads entries from searchDir whose name starts with prefix
// (case-insensitive). Returns candidates sorted by name.
func ReadDirAutocomplete(searchDir, prefix string) ([]AutocompleteCandidate, error) {
entries, err := os.ReadDir(searchDir)
if err != nil {
return nil, err
}
prefixLower := strings.ToLower(prefix)
var results []AutocompleteCandidate
for _, e := range entries {
name := e.Name()
if prefix == "" || strings.HasPrefix(strings.ToLower(name), prefixLower) {
results = append(results, AutocompleteCandidate{Name: name, IsDir: e.IsDir()})
}
}
sort.Slice(results, func(i, j int) bool { return results[i].Name < results[j].Name })
return results, nil
}
+44
View File
@@ -0,0 +1,44 @@
---
name: read_dir_autocomplete
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: impure
signature: "func ReadDirAutocomplete(searchDir, prefix string) ([]AutocompleteCandidate, error)"
description: "Lee entradas de un directorio filtradas por prefijo (case-insensitive). Retorna candidatos ordenados por nombre, utiles para autocompletado de rutas en TUIs."
tags: [tui, autocomplete, directory, path, filter, completion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, sort, strings]
params:
- name: searchDir
desc: "directorio donde buscar entradas"
- name: prefix
desc: "prefijo para filtrar nombres (case-insensitive); vacio retorna todas"
output: "slice de AutocompleteCandidate con Name y IsDir, ordenados alfabeticamente"
tested: false
tests: []
test_file_path: ""
file_path: "functions/tui/read_dir_autocomplete.go"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "launcher/middleware/command_fs.go"
---
## Ejemplo
```go
candidates, err := tui.ReadDirAutocomplete("/home/user", "doc")
// candidates podria ser: [{Name:"Documents" IsDir:true}, {Name:"docker-compose.yml" IsDir:false}]
// Sin prefijo retorna todas las entradas
all, _ := tui.ReadDirAutocomplete("/tmp", "")
```
## Notas
El struct `AutocompleteCandidate` se define en el mismo archivo. El campo `IsDir` permite al caller diferenciar directorios de archivos para renderizar iconos o sufijos distintos.
+11
View File
@@ -0,0 +1,11 @@
package tui
import "regexp"
// ansiEscape matches ANSI terminal escape sequences.
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
// StripANSI removes ANSI terminal escape sequences from s.
func StripANSI(s string) string {
return ansiEscape.ReplaceAllString(s, "")
}
+40
View File
@@ -0,0 +1,40 @@
---
name: strip_ansi
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func StripANSI(s string) string"
description: "Remueve secuencias de escape ANSI de un string. Util para limpiar output de terminal antes de procesarlo o mostrarlo en contextos sin soporte ANSI."
tags: [tui, ansi, strip, terminal, escape, clean]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [regexp]
params:
- name: s
desc: "string con posibles secuencias de escape ANSI"
output: "string sin secuencias ANSI"
tested: false
tests: []
test_file_path: ""
file_path: "functions/tui/strip_ansi.go"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "launcher/core/commands.go"
---
## Ejemplo
```go
raw := "\033[31mError:\033[0m archivo no encontrado"
clean := tui.StripANSI(raw)
// clean == "Error: archivo no encontrado"
```
## Notas
Usa regex `\x1b\[[0-9;]*[a-zA-Z]` que cubre los codigos CSI estandar (colores, cursor, etc.). No cubre secuencias OSC ni DCS menos comunes.