Files
Transmute/cli/internal/tui/views.go
T
noah c9f1242823 refactor: rework TUI layout to match web app terminal simulation
- Fixed column widths for cursor, selection, size, format selector, status
- Flexible name column that adapts to terminal width
- Active row highlighted with warm background color
- Column header aligned with file row columns
- Proper Unicode symbols (arrows, dots, check/cross marks)
- Consistent 2-char padding on dividers and title bar
2026-03-09 23:09:47 +01:00

431 lines
12 KiB
Go

package tui
import (
"fmt"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/noauf/transmute-cli/internal/detect"
"github.com/noauf/transmute-cli/internal/theme"
)
// Fixed column widths (matching the web simulation layout).
const (
colCursor = 3 // "> " or " "
colCheck = 3 // "● " or "○ "
colSize = 10 // right-aligned file size
colFormat = 14 // "< webp >" centered
colStatus = 14 // "idle" / "converting..." / "done"
colGap = 2 // gap between columns
)
// nameWidth calculates the flexible Name column width.
func nameWidth(termW int) int {
fixed := colCursor + colCheck + colSize + colFormat + colStatus + colGap*3
w := termW - fixed
if w < 20 {
w = 20
}
return w
}
// View renders the entire TUI, filling the full terminal.
func (m Model) View() string {
if m.width == 0 || m.height == 0 {
return "Loading..."
}
var sections []string
sections = append(sections, m.renderTitleBar())
sections = append(sections, m.renderDivider())
switch m.state {
case stateFileList:
sections = append(sections, m.renderColumnHeader())
sections = append(sections, m.renderFileList())
sections = append(sections, m.renderDivider())
sections = append(sections, m.renderBottomBar())
case stateConverting:
sections = append(sections, m.renderConverting())
case stateResults:
sections = append(sections, m.renderResults())
sections = append(sections, m.renderDivider())
sections = append(sections, m.renderResultsFooter())
}
if m.showHelp {
sections = append(sections, m.renderHelp())
}
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Split into individual lines and pad each to full width with background
lines := strings.Split(content, "\n")
for i, line := range lines {
lines[i] = theme.PadLine(line, m.width)
}
// Fill remaining vertical space with background-colored blank lines
remaining := m.height - len(lines)
if remaining > 0 {
fill := theme.FillBlankLines(remaining, m.width)
return strings.Join(lines, "\n") + "\n" + fill
}
// Truncate if content exceeds terminal height
if len(lines) > m.height {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
}
// ─── Title bar ───────────────────────────────────────────────
func (m Model) renderTitleBar() string {
title := theme.Logo.Render("transmute")
fileCount := fmt.Sprintf("%d files", len(m.files))
selected := 0
for _, f := range m.files {
if f.selected {
selected++
}
}
info := theme.Breadcrumb.Render(fmt.Sprintf(" %s \u00B7 %d selected", fileCount, selected))
left := " " + title + info
rightContent := theme.Help.Render("? help") + " "
gap := m.width - lipgloss.Width(left) - lipgloss.Width(rightContent)
if gap < 1 {
gap = 1
}
return left + strings.Repeat(" ", gap) + rightContent
}
// ─── Divider ─────────────────────────────────────────────────
func (m Model) renderDivider() string {
w := m.width - 4 // 2 char padding each side
if w < 10 {
w = 10
}
return " " + theme.Divider.Render(strings.Repeat("\u2500", w)) + " "
}
// ─── Column header ───────────────────────────────────────────
func (m Model) renderColumnHeader() string {
nw := nameWidth(m.width)
// "Name" sits after the cursor+check prefix (6 chars), indented
nameHdr := theme.Breadcrumb.Copy().Width(colCursor + colCheck + nw).Render(
strings.Repeat(" ", colCursor+colCheck) + "Name")
sizeHdr := theme.Breadcrumb.Copy().Width(colSize).Align(lipgloss.Right).Render("Size")
fmtHdr := theme.Breadcrumb.Copy().Width(colFormat).Align(lipgloss.Center).Render("Convert to")
statHdr := theme.Breadcrumb.Copy().Width(colStatus).Align(lipgloss.Center).Render("Status")
return nameHdr + " " + sizeHdr + " " + fmtHdr + " " + statHdr
}
// ─── File list ───────────────────────────────────────────────
func (m Model) renderFileList() string {
if len(m.files) == 0 {
empty := lipgloss.NewStyle().
Foreground(theme.Light).
Italic(true).
Padding(2, 4).
Render("No supported files found. Pass file paths or glob patterns as arguments.")
return empty
}
maxVisible := m.maxVisibleFiles()
end := m.scroll + maxVisible
if end > len(m.files) {
end = len(m.files)
}
var rows []string
for i := m.scroll; i < end; i++ {
rows = append(rows, m.renderFileRow(i))
}
// Pad with empty rows so the file list always fills the available space
rendered := len(rows)
if rendered < maxVisible {
emptyRow := strings.Repeat(" ", m.width)
for i := rendered; i < maxVisible; i++ {
rows = append(rows, emptyRow)
}
}
// Scrollbar indicator
if len(m.files) > maxVisible {
scrollInfo := theme.Help.Render(fmt.Sprintf(
" showing %d\u2013%d of %d", m.scroll+1, end, len(m.files)))
rows = append(rows, scrollInfo)
}
return lipgloss.JoinVertical(lipgloss.Left, rows...)
}
func (m Model) renderFileRow(idx int) string {
f := m.files[idx]
isCursor := idx == m.cursor
nw := nameWidth(m.width)
// ── Cursor indicator ──
cursor := " "
if isCursor {
cursor = " " + theme.Selected.Render(">") + " "
}
// ── Selection dot ──
check := theme.Breadcrumb.Render("\u25CB") + " " // ○
if f.selected {
check = theme.StatusDone.Render("\u25CF") + " " // ● (mint)
}
// ── Icon + ext badge + filename ──
icon := detect.CategoryIcon(f.category)
catColor := theme.CategoryColor(string(f.category))
extBadge := theme.ExtBadge(catColor).Render(strings.ToUpper(f.ext))
nameText := f.name
// Reserve space for icon(2) + space(1) + ext(max 5) + space(1) + ...
maxName := nw - 10
if maxName < 8 {
maxName = 8
}
if len(nameText) > maxName {
nameText = nameText[:maxName-3] + "..."
}
var nameStyle lipgloss.Style
if isCursor {
nameStyle = theme.FileName.Copy().Bold(true)
} else {
nameStyle = theme.FileName
}
// Build the name cell content, then wrap in a fixed-width style
nameContent := icon + " " + extBadge + " " + nameStyle.Render(nameText)
nameCell := lipgloss.NewStyle().Width(nw).MaxWidth(nw).Render(nameContent)
// ── Size ──
sizeCell := theme.FileSize.Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
// ── Format selector ──
fmtStr := renderFormatSelector(f, isCursor)
fmtCell := lipgloss.NewStyle().Width(colFormat).Align(lipgloss.Center).Render(fmtStr)
// ── Status ──
var statusStr string
switch f.status {
case "idle":
statusStr = theme.StatusIdle.Render("idle")
case "converting":
statusStr = theme.StatusConverting.Render("converting...")
case "done":
statusStr = theme.StatusDone.Render("done")
case "error":
statusStr = theme.StatusError.Render("error")
}
statusCell := lipgloss.NewStyle().Width(colStatus).Align(lipgloss.Center).Render(statusStr)
// ── Assemble row ──
row := cursor + check + nameCell + " " + sizeCell + " " + fmtCell + " " + statusCell
// Highlight active row with warm background
if isCursor {
row = lipgloss.NewStyle().Background(theme.Warm).Render(row)
}
return row
}
func renderFormatSelector(f fileEntry, active bool) string {
if len(f.formats) == 0 {
return theme.Help.Render("\u2014")
}
left := " "
right := " "
if active && f.formatIdx > 0 {
left = theme.Help.Render("< ")
}
if active && f.formatIdx < len(f.formats)-1 {
right = theme.Help.Render(" >")
}
var middle string
if active {
middle = theme.Selected.Render(f.targetFormat)
} else {
middle = theme.Unselected.Render(f.targetFormat)
}
return left + middle + right
}
// ─── Bottom bar ──────────────────────────────────────────────
func (m Model) renderBottomBar() string {
selected := 0
for _, f := range m.files {
if f.selected {
selected++
}
}
var left string
if selected > 0 {
left = " " + theme.ButtonPrimary.Render(fmt.Sprintf(" Convert %d files [c] ", selected))
} else {
left = " " + theme.Help.Render("Select files to convert")
}
right := theme.Help.Render("up/down navigate left/right format space select a all q quit") + " "
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return left + strings.Repeat(" ", gap) + right
}
// ─── Converting view ─────────────────────────────────────────
func (m Model) renderConverting() string {
elapsed := time.Since(m.startTime).Round(time.Millisecond)
header := theme.StatusConverting.Render(fmt.Sprintf(
" Converting... %d/%d (%s)", m.converted, m.totalToConv, elapsed))
// Progress bar
barWidth := m.width - 8
if barWidth < 20 {
barWidth = 20
}
progress := float64(m.converted) / float64(m.totalToConv)
filled := int(progress * float64(barWidth))
if filled > barWidth {
filled = barWidth
}
bar := " " +
theme.ProgressFilled.Render(strings.Repeat("\u2588", filled)) +
theme.ProgressEmpty.Render(strings.Repeat("\u2591", barWidth-filled))
// Show current files being converted
var current []string
for _, f := range m.files {
if f.status == "converting" {
current = append(current, fmt.Sprintf(" %s \u2192 %s", f.name, f.targetFormat))
}
}
currentStr := theme.Help.Render(strings.Join(current, "\n"))
return lipgloss.JoinVertical(lipgloss.Left,
"",
header,
bar,
"",
currentStr,
"",
theme.Help.Render(" Press q to cancel"),
)
}
// ─── Results view ────────────────────────────────────────────
func (m Model) renderResults() string {
var rows []string
successCount := 0
errorCount := 0
for _, f := range m.files {
if !f.selected {
continue
}
if f.status == "done" {
successCount++
} else if f.status == "error" {
errorCount++
}
}
elapsed := time.Since(m.startTime).Round(time.Millisecond)
summary := theme.StatusDone.Render(fmt.Sprintf(
" Conversion complete! %d succeeded", successCount))
if errorCount > 0 {
summary += theme.StatusError.Render(fmt.Sprintf(", %d failed", errorCount))
}
summary += theme.Help.Render(fmt.Sprintf(" (%s)", elapsed))
rows = append(rows, "", summary, "")
// List results
for _, f := range m.files {
if !f.selected {
continue
}
switch f.status {
case "done":
rows = append(rows, theme.StatusDone.Render(" \u2713 ")+
theme.FileName.Render(f.name)+
theme.Help.Render(" \u2192 ")+
theme.BreadcrumbActive.Render(f.outputPath))
case "error":
rows = append(rows, theme.StatusError.Render(" \u2717 ")+
theme.FileName.Render(f.name)+
theme.Help.Render(" \u2014 ")+
theme.StatusError.Render(f.error))
}
}
return lipgloss.JoinVertical(lipgloss.Left, rows...)
}
func (m Model) renderResultsFooter() string {
return theme.Help.Render(" Press enter to exit | esc to convert more")
}
// ─── Help overlay ────────────────────────────────────────────
func (m Model) renderHelp() string {
keys := []struct {
key string
desc string
}{
{"up/down, j/k", "Navigate files"},
{"left/right, h/l", "Change target format"},
{"space", "Toggle file selection"},
{"a", "Select / deselect all"},
{"d", "Remove file from list"},
{"c or enter", "Start conversion"},
{"esc", "Go back"},
{"q or ctrl+c", "Quit"},
}
var lines []string
lines = append(lines, "")
lines = append(lines, theme.BreadcrumbActive.Render(" Keyboard Shortcuts"))
lines = append(lines, "")
for _, k := range keys {
lines = append(lines, fmt.Sprintf(" %s %s",
theme.Selected.Copy().Width(18).Render(k.key),
theme.Help.Render(k.desc)))
}
lines = append(lines, "")
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}