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:
+113
-97
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user