4d5e73cfd1
Every Lip Gloss style now has Background(ScreenBg) so ANSI sequences carry the cream color inherently. Previously only PadLine added bg to padding spaces, but inner escape codes reset the background. All spacing between styled segments uses bg()/wbg() helpers. Cursor row elements use Background(Warm) for highlight.
526 lines
15 KiB
Go
526 lines
15 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
|
|
)
|
|
|
|
// bg renders spacing text with the cream background.
|
|
func bg(s string) string {
|
|
return theme.BgStyle.Render(s)
|
|
}
|
|
|
|
// wbg renders spacing text with the warm highlight background.
|
|
func wbg(s string) string {
|
|
return theme.WarmBgStyle.Render(s)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// pad pads a line to full width with cream background.
|
|
func (m Model) pad(line string) string {
|
|
return theme.PadLine(line, m.width)
|
|
}
|
|
|
|
// padWarm pads a line to full width with the warm (highlighted) background.
|
|
func (m Model) padWarm(line string) string {
|
|
return theme.PadLineWithBg(line, m.width, theme.Warm)
|
|
}
|
|
|
|
// blank returns a full-width blank line with cream background.
|
|
func (m Model) blank() string {
|
|
return theme.BgStyle.Render(strings.Repeat(" ", m.width))
|
|
}
|
|
|
|
// View renders the entire TUI, filling the full terminal.
|
|
func (m Model) View() string {
|
|
if m.width == 0 || m.height == 0 {
|
|
return "Loading..."
|
|
}
|
|
|
|
// We build an array of pre-padded lines (each already full-width with bg).
|
|
var lines []string
|
|
|
|
// Title bar (1 line)
|
|
lines = append(lines, m.pad(m.renderTitleBar()))
|
|
|
|
// Divider (1 line)
|
|
lines = append(lines, m.pad(m.renderDivider()))
|
|
|
|
// State-specific content
|
|
var bottomLines []string // lines that go at the very bottom
|
|
|
|
switch m.state {
|
|
case stateFileList:
|
|
// Column header
|
|
lines = append(lines, m.pad(m.renderColumnHeader()))
|
|
|
|
// File rows
|
|
fileLines := m.renderFileRows()
|
|
lines = append(lines, fileLines...)
|
|
|
|
// Bottom section: divider + bottom bar (pinned to bottom)
|
|
bottomLines = append(bottomLines, m.pad(m.renderDivider()))
|
|
bottomLines = append(bottomLines, m.pad(m.renderBottomBar()))
|
|
|
|
case stateConverting:
|
|
for _, l := range strings.Split(m.renderConverting(), "\n") {
|
|
lines = append(lines, m.pad(l))
|
|
}
|
|
|
|
case stateResults:
|
|
for _, l := range strings.Split(m.renderResults(), "\n") {
|
|
lines = append(lines, m.pad(l))
|
|
}
|
|
bottomLines = append(bottomLines, m.pad(m.renderDivider()))
|
|
bottomLines = append(bottomLines, m.pad(m.renderResultsFooter()))
|
|
}
|
|
|
|
// Help overlay (if visible, goes right after content)
|
|
if m.showHelp {
|
|
for _, l := range strings.Split(m.renderHelp(), "\n") {
|
|
lines = append(lines, m.pad(l))
|
|
}
|
|
}
|
|
|
|
// Calculate how many blank lines we need between content and bottom bar
|
|
totalUsed := len(lines) + len(bottomLines)
|
|
remaining := m.height - totalUsed
|
|
for i := 0; i < remaining; i++ {
|
|
lines = append(lines, m.blank())
|
|
}
|
|
|
|
// Append bottom bar lines
|
|
lines = append(lines, bottomLines...)
|
|
|
|
// Truncate if somehow we exceed 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 := bg(" ") + title + info
|
|
rightContent := theme.Help.Render("? help") + bg(" ")
|
|
gap := m.width - lipgloss.Width(left) - lipgloss.Width(rightContent)
|
|
if gap < 1 {
|
|
gap = 1
|
|
}
|
|
|
|
return left + bg(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 bg(" ") + theme.Divider.Render(strings.Repeat("\u2500", w)) + bg(" ")
|
|
}
|
|
|
|
// ─── Column header ───────────────────────────────────────────
|
|
|
|
func (m Model) renderColumnHeader() string {
|
|
nw := nameWidth(m.width)
|
|
|
|
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 + bg(" ") + sizeHdr + bg(" ") + fmtHdr + bg(" ") + statHdr
|
|
}
|
|
|
|
// ─── File rows ───────────────────────────────────────────────
|
|
|
|
// renderFileRows returns already-padded lines for the file list area.
|
|
func (m Model) renderFileRows() []string {
|
|
if len(m.files) == 0 {
|
|
empty := lipgloss.NewStyle().
|
|
Foreground(theme.Light).
|
|
Background(theme.ScreenBg).
|
|
Italic(true).
|
|
Render(" No supported files found. Pass file paths or glob patterns as arguments.")
|
|
return []string{m.blank(), m.pad(empty), m.blank()}
|
|
}
|
|
|
|
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++ {
|
|
isCursor := i == m.cursor
|
|
row := m.renderFileRow(i)
|
|
if isCursor {
|
|
rows = append(rows, m.padWarm(row))
|
|
} else {
|
|
rows = append(rows, m.pad(row))
|
|
}
|
|
}
|
|
|
|
// Scrollbar indicator (if needed)
|
|
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, m.pad(scrollInfo))
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
func (m Model) renderFileRow(idx int) string {
|
|
f := m.files[idx]
|
|
isCursor := idx == m.cursor
|
|
nw := nameWidth(m.width)
|
|
|
|
// Choose background helper based on whether this is the cursor row
|
|
sp := bg
|
|
if isCursor {
|
|
sp = wbg
|
|
}
|
|
|
|
// ── Cursor indicator ──
|
|
cursor := sp(" ")
|
|
if isCursor {
|
|
cursor = sp(" ") + theme.Selected.Copy().Background(theme.Warm).Render(">") + sp(" ")
|
|
}
|
|
|
|
// ── Selection dot ──
|
|
var check string
|
|
if f.selected {
|
|
if isCursor {
|
|
check = theme.StatusDone.Copy().Background(theme.Warm).Render("\u25CF") + sp(" ")
|
|
} else {
|
|
check = theme.StatusDone.Render("\u25CF") + sp(" ")
|
|
}
|
|
} else {
|
|
if isCursor {
|
|
check = theme.Breadcrumb.Copy().Background(theme.Warm).Render("\u25CB") + sp(" ")
|
|
} else {
|
|
check = theme.Breadcrumb.Render("\u25CB") + sp(" ")
|
|
}
|
|
}
|
|
|
|
// ── Icon + ext badge + filename ──
|
|
icon := detect.CategoryIcon(f.category)
|
|
catColor := theme.CategoryColor(string(f.category))
|
|
|
|
var extBadge string
|
|
if isCursor {
|
|
extBadge = theme.ExtBadge(catColor).Background(theme.Warm).Render(strings.ToUpper(f.ext))
|
|
} else {
|
|
extBadge = theme.ExtBadge(catColor).Render(strings.ToUpper(f.ext))
|
|
}
|
|
|
|
nameText := f.name
|
|
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().Background(theme.Warm).Bold(true)
|
|
} else {
|
|
nameStyle = theme.FileName
|
|
}
|
|
|
|
nameContent := sp(icon+" ") + extBadge + sp(" ") + nameStyle.Render(nameText)
|
|
var nameCellStyle lipgloss.Style
|
|
if isCursor {
|
|
nameCellStyle = lipgloss.NewStyle().Width(nw).MaxWidth(nw).Background(theme.Warm)
|
|
} else {
|
|
nameCellStyle = lipgloss.NewStyle().Width(nw).MaxWidth(nw).Background(theme.ScreenBg)
|
|
}
|
|
nameCell := nameCellStyle.Render(nameContent)
|
|
|
|
// ── Size ──
|
|
var sizeCell string
|
|
if isCursor {
|
|
sizeCell = theme.FileSize.Copy().Background(theme.Warm).Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
|
|
} else {
|
|
sizeCell = theme.FileSize.Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
|
|
}
|
|
|
|
// ── Format selector ──
|
|
fmtStr := renderFormatSelector(f, isCursor)
|
|
var fmtCell string
|
|
if isCursor {
|
|
fmtCell = lipgloss.NewStyle().Width(colFormat).Align(lipgloss.Center).Background(theme.Warm).Render(fmtStr)
|
|
} else {
|
|
fmtCell = lipgloss.NewStyle().Width(colFormat).Align(lipgloss.Center).Background(theme.ScreenBg).Render(fmtStr)
|
|
}
|
|
|
|
// ── Status ──
|
|
var statusStr string
|
|
switch f.status {
|
|
case "idle":
|
|
if isCursor {
|
|
statusStr = theme.StatusIdle.Copy().Background(theme.Warm).Render("idle")
|
|
} else {
|
|
statusStr = theme.StatusIdle.Render("idle")
|
|
}
|
|
case "converting":
|
|
if isCursor {
|
|
statusStr = theme.StatusConverting.Copy().Background(theme.Warm).Render("converting...")
|
|
} else {
|
|
statusStr = theme.StatusConverting.Render("converting...")
|
|
}
|
|
case "done":
|
|
if isCursor {
|
|
statusStr = theme.StatusDone.Copy().Background(theme.Warm).Render("done")
|
|
} else {
|
|
statusStr = theme.StatusDone.Render("done")
|
|
}
|
|
case "error":
|
|
if isCursor {
|
|
statusStr = theme.StatusError.Copy().Background(theme.Warm).Render("error")
|
|
} else {
|
|
statusStr = theme.StatusError.Render("error")
|
|
}
|
|
}
|
|
var statusCell string
|
|
if isCursor {
|
|
statusCell = lipgloss.NewStyle().Width(colStatus).Align(lipgloss.Center).Background(theme.Warm).Render(statusStr)
|
|
} else {
|
|
statusCell = lipgloss.NewStyle().Width(colStatus).Align(lipgloss.Center).Background(theme.ScreenBg).Render(statusStr)
|
|
}
|
|
|
|
return cursor + check + nameCell + sp(" ") + sizeCell + sp(" ") + fmtCell + sp(" ") + statusCell
|
|
}
|
|
|
|
func renderFormatSelector(f fileEntry, active bool) string {
|
|
if len(f.formats) == 0 {
|
|
if active {
|
|
return theme.Help.Copy().Background(theme.Warm).Render("\u2014")
|
|
}
|
|
return theme.Help.Render("\u2014")
|
|
}
|
|
|
|
sp := bg
|
|
if active {
|
|
sp = wbg
|
|
}
|
|
|
|
left := sp(" ")
|
|
right := sp(" ")
|
|
if active && f.formatIdx > 0 {
|
|
left = theme.Help.Copy().Background(theme.Warm).Render("< ")
|
|
}
|
|
if active && f.formatIdx < len(f.formats)-1 {
|
|
right = theme.Help.Copy().Background(theme.Warm).Render(" >")
|
|
}
|
|
|
|
var middle string
|
|
if active {
|
|
middle = theme.Selected.Copy().Background(theme.Warm).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 = bg(" ") + theme.ButtonPrimary.Render(fmt.Sprintf(" Convert %d files [c] ", selected))
|
|
} else {
|
|
left = bg(" ") + theme.Help.Render("Select files to convert")
|
|
}
|
|
|
|
right := theme.Help.Render("up/down navigate left/right format space select a all q quit") + bg(" ")
|
|
|
|
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
|
if gap < 1 {
|
|
gap = 1
|
|
}
|
|
|
|
return left + bg(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 := bg(" ") +
|
|
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, bg(" ")+
|
|
theme.Selected.Copy().Width(18).Render(k.key)+bg(" ")+
|
|
theme.Help.Render(k.desc))
|
|
}
|
|
lines = append(lines, "")
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
}
|