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...) }