diff --git a/cli/internal/tui/views.go b/cli/internal/tui/views.go index 5712684..1a756ee 100644 --- a/cli/internal/tui/views.go +++ b/cli/internal/tui/views.go @@ -11,6 +11,26 @@ import ( "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 { @@ -24,6 +44,7 @@ func (m Model) View() string { 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()) @@ -74,27 +95,41 @@ func (m Model) renderTitleBar() string { 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 - padding := "" - rightContent := theme.Help.Render("? help") - totalWidth := lipgloss.Width(left) + lipgloss.Width(rightContent) + 2 - if m.width > totalWidth { - padding = strings.Repeat(" ", m.width-totalWidth) + left := " " + title + info + rightContent := theme.Help.Render("? help") + " " + gap := m.width - lipgloss.Width(left) - lipgloss.Width(rightContent) + if gap < 1 { + gap = 1 } - return left + padding + rightContent + return left + strings.Repeat(" ", gap) + rightContent } // ─── Divider ───────────────────────────────────────────────── func (m Model) renderDivider() string { - w := m.width - if w <= 0 { - w = 60 + w := m.width - 4 // 2 char padding each side + if w < 10 { + 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 ─────────────────────────────────────────────── @@ -109,9 +144,6 @@ func (m Model) renderFileList() string { return empty } - // Column header - header := renderColumnHeader(m.width) - maxVisible := m.maxVisibleFiles() end := m.scroll + maxVisible if end > len(m.files) { @@ -119,14 +151,12 @@ func (m Model) renderFileList() string { } var rows []string - rows = append(rows, header) - 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) - 1 // subtract header + rendered := len(rows) if rendered < maxVisible { emptyRow := strings.Repeat(" ", m.width) for i := rendered; i < maxVisible; i++ { @@ -137,62 +167,43 @@ func (m Model) renderFileList() string { // Scrollbar indicator if len(m.files) > maxVisible { 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) } 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 { f := m.files[idx] isCursor := idx == m.cursor + nw := nameWidth(m.width) - nameW := 30 - sizeW := 10 - formatW := 20 - statusW := 12 - - if m.width > 100 { - nameW = m.width - sizeW - formatW - statusW - 12 - } - - // Cursor + selection indicator - prefix := " " + // ── Cursor indicator ── + cursor := " " if isCursor { - prefix = theme.Selected.Render("> ") + cursor = " " + theme.Selected.Render(">") + " " } - // Checkbox - check := "○" + // ── Selection dot ── + check := theme.Breadcrumb.Render("\u25CB") + " " // ○ if f.selected { - check = theme.StatusDone.Render("●") + check = theme.StatusDone.Render("\u25CF") + " " // ● (mint) } - // Category icon + file name - catColor := theme.CategoryColor(string(f.category)) + // ── 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 - if len(nameText) > nameW-8 { - nameText = nameText[:nameW-11] + "..." + // 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 @@ -202,17 +213,18 @@ func (m Model) renderFileRow(idx int) string { nameStyle = theme.FileName } - nameCol := lipgloss.NewStyle().Width(nameW).Render( - prefix + check + " " + icon + " " + theme.ExtBadge(catColor).Render(strings.ToUpper(f.ext)) + " " + nameStyle.Render(nameText)) + // 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 - sizeCol := theme.FileSize.Copy().Width(sizeW).Align(lipgloss.Right).Render(formatSize(f.size)) + // ── Size ── + sizeCell := theme.FileSize.Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size)) - // Format selector - formatStr := renderFormatSelector(f, isCursor) - formatCol := lipgloss.NewStyle().Width(formatW).Align(lipgloss.Center).Render(formatStr) + // ── Format selector ── + fmtStr := renderFormatSelector(f, isCursor) + fmtCell := lipgloss.NewStyle().Width(colFormat).Align(lipgloss.Center).Render(fmtStr) - // Status + // ── Status ── var statusStr string switch f.status { case "idle": @@ -224,36 +236,41 @@ func (m Model) renderFileRow(idx int) string { case "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 { if len(f.formats) == 0 { - return theme.Help.Render("—") + return theme.Help.Render("\u2014") } - var parts []string + left := " " + right := " " if active && f.formatIdx > 0 { - parts = append(parts, theme.Help.Render("< ")) - } else { - parts = append(parts, " ") + left = theme.Help.Render("< ") } - - 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 { - parts = append(parts, theme.Help.Render(" >")) - } else { - parts = append(parts, " ") + right = theme.Help.Render(" >") } - 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 ────────────────────────────────────────────── @@ -268,20 +285,19 @@ func (m Model) renderBottomBar() string { var left string 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 { - 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 := "" - totalWidth := lipgloss.Width(left) + lipgloss.Width(right) - if m.width > totalWidth { - padding = strings.Repeat(" ", m.width-totalWidth) + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 } - return left + padding + right + return left + strings.Repeat(" ", gap) + right } // ─── Converting view ───────────────────────────────────────── @@ -304,14 +320,14 @@ func (m Model) renderConverting() string { } bar := " " + - theme.ProgressFilled.Render(strings.Repeat("█", filled)) + - theme.ProgressEmpty.Render(strings.Repeat("░", barWidth-filled)) + 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 -> %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")) @@ -362,14 +378,14 @@ func (m Model) renderResults() string { } switch f.status { case "done": - rows = append(rows, theme.StatusDone.Render(" done ")+ + rows = append(rows, theme.StatusDone.Render(" \u2713 ")+ theme.FileName.Render(f.name)+ - theme.Help.Render(" -> ")+ + theme.Help.Render(" \u2192 ")+ theme.BreadcrumbActive.Render(f.outputPath)) case "error": - rows = append(rows, theme.StatusError.Render(" fail ")+ + rows = append(rows, theme.StatusError.Render(" \u2717 ")+ theme.FileName.Render(f.name)+ - theme.Help.Render(" -- ")+ + theme.Help.Render(" \u2014 ")+ theme.StatusError.Render(f.error)) } }