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
This commit is contained in:
noah
2026-03-09 23:09:47 +01:00
parent b47399335a
commit c9f1242823
+113 -97
View File
@@ -11,6 +11,26 @@ import (
"github.com/noauf/transmute-cli/internal/theme" "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. // View renders the entire TUI, filling the full terminal.
func (m Model) View() string { func (m Model) View() string {
if m.width == 0 || m.height == 0 { if m.width == 0 || m.height == 0 {
@@ -24,6 +44,7 @@ func (m Model) View() string {
switch m.state { switch m.state {
case stateFileList: case stateFileList:
sections = append(sections, m.renderColumnHeader())
sections = append(sections, m.renderFileList()) sections = append(sections, m.renderFileList())
sections = append(sections, m.renderDivider()) sections = append(sections, m.renderDivider())
sections = append(sections, m.renderBottomBar()) sections = append(sections, m.renderBottomBar())
@@ -74,27 +95,41 @@ func (m Model) renderTitleBar() string {
selected++ selected++
} }
} }
info := theme.Breadcrumb.Render(fmt.Sprintf(" %s · %d selected", fileCount, selected)) info := theme.Breadcrumb.Render(fmt.Sprintf(" %s \u00B7 %d selected", fileCount, selected))
left := title + info left := " " + title + info
padding := "" rightContent := theme.Help.Render("? help") + " "
rightContent := theme.Help.Render("? help") gap := m.width - lipgloss.Width(left) - lipgloss.Width(rightContent)
totalWidth := lipgloss.Width(left) + lipgloss.Width(rightContent) + 2 if gap < 1 {
if m.width > totalWidth { gap = 1
padding = strings.Repeat(" ", m.width-totalWidth)
} }
return left + padding + rightContent return left + strings.Repeat(" ", gap) + rightContent
} }
// ─── Divider ───────────────────────────────────────────────── // ─── Divider ─────────────────────────────────────────────────
func (m Model) renderDivider() string { func (m Model) renderDivider() string {
w := m.width w := m.width - 4 // 2 char padding each side
if w <= 0 { if w < 10 {
w = 60 w = 10
} }
return theme.Divider.Render(strings.Repeat("", w)) 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 ─────────────────────────────────────────────── // ─── File list ───────────────────────────────────────────────
@@ -109,9 +144,6 @@ func (m Model) renderFileList() string {
return empty return empty
} }
// Column header
header := renderColumnHeader(m.width)
maxVisible := m.maxVisibleFiles() maxVisible := m.maxVisibleFiles()
end := m.scroll + maxVisible end := m.scroll + maxVisible
if end > len(m.files) { if end > len(m.files) {
@@ -119,14 +151,12 @@ func (m Model) renderFileList() string {
} }
var rows []string var rows []string
rows = append(rows, header)
for i := m.scroll; i < end; i++ { for i := m.scroll; i < end; i++ {
rows = append(rows, m.renderFileRow(i)) rows = append(rows, m.renderFileRow(i))
} }
// Pad with empty rows so the file list always fills the available space // Pad with empty rows so the file list always fills the available space
rendered := len(rows) - 1 // subtract header rendered := len(rows)
if rendered < maxVisible { if rendered < maxVisible {
emptyRow := strings.Repeat(" ", m.width) emptyRow := strings.Repeat(" ", m.width)
for i := rendered; i < maxVisible; i++ { for i := rendered; i < maxVisible; i++ {
@@ -137,62 +167,43 @@ func (m Model) renderFileList() string {
// Scrollbar indicator // Scrollbar indicator
if len(m.files) > maxVisible { if len(m.files) > maxVisible {
scrollInfo := theme.Help.Render(fmt.Sprintf( scrollInfo := theme.Help.Render(fmt.Sprintf(
" showing %d%d of %d", m.scroll+1, end, len(m.files))) " showing %d\u2013%d of %d", m.scroll+1, end, len(m.files)))
rows = append(rows, scrollInfo) rows = append(rows, scrollInfo)
} }
return lipgloss.JoinVertical(lipgloss.Left, rows...) return lipgloss.JoinVertical(lipgloss.Left, rows...)
} }
func renderColumnHeader(width int) string {
nameW := 30
sizeW := 10
formatW := 20
statusW := 12
if width > 100 {
nameW = width - sizeW - formatW - statusW - 12
}
name := theme.Breadcrumb.Copy().Width(nameW).Render(" Name")
size := theme.Breadcrumb.Copy().Width(sizeW).Align(lipgloss.Right).Render("Size")
format := theme.Breadcrumb.Copy().Width(formatW).Align(lipgloss.Center).Render("Convert to")
status := theme.Breadcrumb.Copy().Width(statusW).Align(lipgloss.Center).Render("Status")
return name + size + " " + format + " " + status
}
func (m Model) renderFileRow(idx int) string { func (m Model) renderFileRow(idx int) string {
f := m.files[idx] f := m.files[idx]
isCursor := idx == m.cursor isCursor := idx == m.cursor
nw := nameWidth(m.width)
nameW := 30 // ── Cursor indicator ──
sizeW := 10 cursor := " "
formatW := 20
statusW := 12
if m.width > 100 {
nameW = m.width - sizeW - formatW - statusW - 12
}
// Cursor + selection indicator
prefix := " "
if isCursor { if isCursor {
prefix = theme.Selected.Render("> ") cursor = " " + theme.Selected.Render(">") + " "
} }
// Checkbox // ── Selection dot ──
check := "○" check := theme.Breadcrumb.Render("\u25CB") + " " // ○
if f.selected { if f.selected {
check = theme.StatusDone.Render("●") check = theme.StatusDone.Render("\u25CF") + " " // ● (mint)
} }
// Category icon + file name // ── Icon + ext badge + filename ──
catColor := theme.CategoryColor(string(f.category))
icon := detect.CategoryIcon(f.category) icon := detect.CategoryIcon(f.category)
catColor := theme.CategoryColor(string(f.category))
extBadge := theme.ExtBadge(catColor).Render(strings.ToUpper(f.ext))
nameText := f.name nameText := f.name
if len(nameText) > nameW-8 { // Reserve space for icon(2) + space(1) + ext(max 5) + space(1) + ...
nameText = nameText[:nameW-11] + "..." maxName := nw - 10
if maxName < 8 {
maxName = 8
}
if len(nameText) > maxName {
nameText = nameText[:maxName-3] + "..."
} }
var nameStyle lipgloss.Style var nameStyle lipgloss.Style
@@ -202,17 +213,18 @@ func (m Model) renderFileRow(idx int) string {
nameStyle = theme.FileName nameStyle = theme.FileName
} }
nameCol := lipgloss.NewStyle().Width(nameW).Render( // Build the name cell content, then wrap in a fixed-width style
prefix + check + " " + icon + " " + theme.ExtBadge(catColor).Render(strings.ToUpper(f.ext)) + " " + nameStyle.Render(nameText)) nameContent := icon + " " + extBadge + " " + nameStyle.Render(nameText)
nameCell := lipgloss.NewStyle().Width(nw).MaxWidth(nw).Render(nameContent)
// Size // ── Size ──
sizeCol := theme.FileSize.Copy().Width(sizeW).Align(lipgloss.Right).Render(formatSize(f.size)) sizeCell := theme.FileSize.Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
// Format selector // ── Format selector ──
formatStr := renderFormatSelector(f, isCursor) fmtStr := renderFormatSelector(f, isCursor)
formatCol := lipgloss.NewStyle().Width(formatW).Align(lipgloss.Center).Render(formatStr) fmtCell := lipgloss.NewStyle().Width(colFormat).Align(lipgloss.Center).Render(fmtStr)
// Status // ── Status ──
var statusStr string var statusStr string
switch f.status { switch f.status {
case "idle": case "idle":
@@ -224,36 +236,41 @@ func (m Model) renderFileRow(idx int) string {
case "error": case "error":
statusStr = theme.StatusError.Render("error") statusStr = theme.StatusError.Render("error")
} }
statusCol := lipgloss.NewStyle().Width(statusW).Align(lipgloss.Center).Render(statusStr) statusCell := lipgloss.NewStyle().Width(colStatus).Align(lipgloss.Center).Render(statusStr)
return nameCol + sizeCol + " " + formatCol + " " + statusCol // ── 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 { func renderFormatSelector(f fileEntry, active bool) string {
if len(f.formats) == 0 { if len(f.formats) == 0 {
return theme.Help.Render("") return theme.Help.Render("\u2014")
} }
var parts []string left := " "
right := " "
if active && f.formatIdx > 0 { if active && f.formatIdx > 0 {
parts = append(parts, theme.Help.Render("< ")) left = theme.Help.Render("< ")
} else {
parts = append(parts, " ")
} }
if active {
parts = append(parts, theme.Selected.Render(f.targetFormat))
} else {
parts = append(parts, theme.Unselected.Render(f.targetFormat))
}
if active && f.formatIdx < len(f.formats)-1 { if active && f.formatIdx < len(f.formats)-1 {
parts = append(parts, theme.Help.Render(" >")) right = theme.Help.Render(" >")
} else {
parts = append(parts, " ")
} }
return strings.Join(parts, "") var middle string
if active {
middle = theme.Selected.Render(f.targetFormat)
} else {
middle = theme.Unselected.Render(f.targetFormat)
}
return left + middle + right
} }
// ─── Bottom bar ────────────────────────────────────────────── // ─── Bottom bar ──────────────────────────────────────────────
@@ -268,20 +285,19 @@ func (m Model) renderBottomBar() string {
var left string var left string
if selected > 0 { if selected > 0 {
left = theme.ButtonPrimary.Render(fmt.Sprintf(" Convert %d files [c] ", selected)) left = " " + theme.ButtonPrimary.Render(fmt.Sprintf(" Convert %d files [c] ", selected))
} else { } else {
left = theme.Help.Render("Select files to convert") left = " " + theme.Help.Render("Select files to convert")
} }
right := theme.Help.Render("up/down navigate left/right format space select a all d remove q quit") right := theme.Help.Render("up/down navigate left/right format space select a all q quit") + " "
padding := "" gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
totalWidth := lipgloss.Width(left) + lipgloss.Width(right) if gap < 1 {
if m.width > totalWidth { gap = 1
padding = strings.Repeat(" ", m.width-totalWidth)
} }
return left + padding + right return left + strings.Repeat(" ", gap) + right
} }
// ─── Converting view ───────────────────────────────────────── // ─── Converting view ─────────────────────────────────────────
@@ -304,14 +320,14 @@ func (m Model) renderConverting() string {
} }
bar := " " + bar := " " +
theme.ProgressFilled.Render(strings.Repeat("", filled)) + theme.ProgressFilled.Render(strings.Repeat("\u2588", filled)) +
theme.ProgressEmpty.Render(strings.Repeat("", barWidth-filled)) theme.ProgressEmpty.Render(strings.Repeat("\u2591", barWidth-filled))
// Show current files being converted // Show current files being converted
var current []string var current []string
for _, f := range m.files { for _, f := range m.files {
if f.status == "converting" { if f.status == "converting" {
current = append(current, fmt.Sprintf(" %s -> %s", f.name, f.targetFormat)) current = append(current, fmt.Sprintf(" %s \u2192 %s", f.name, f.targetFormat))
} }
} }
currentStr := theme.Help.Render(strings.Join(current, "\n")) currentStr := theme.Help.Render(strings.Join(current, "\n"))
@@ -362,14 +378,14 @@ func (m Model) renderResults() string {
} }
switch f.status { switch f.status {
case "done": case "done":
rows = append(rows, theme.StatusDone.Render(" done ")+ rows = append(rows, theme.StatusDone.Render(" \u2713 ")+
theme.FileName.Render(f.name)+ theme.FileName.Render(f.name)+
theme.Help.Render(" -> ")+ theme.Help.Render(" \u2192 ")+
theme.BreadcrumbActive.Render(f.outputPath)) theme.BreadcrumbActive.Render(f.outputPath))
case "error": case "error":
rows = append(rows, theme.StatusError.Render(" fail ")+ rows = append(rows, theme.StatusError.Render(" \u2717 ")+
theme.FileName.Render(f.name)+ theme.FileName.Render(f.name)+
theme.Help.Render(" -- ")+ theme.Help.Render(" \u2014 ")+
theme.StatusError.Render(f.error)) theme.StatusError.Render(f.error))
} }
} }