ed25ffa533
Previous approach using Place() for full-screen bg and WarmRowStyle wrapper failed because inner ANSI resets (\x1b[m) kill outer Background() styles. New approach: every styled segment carries its own Background() via theme.Bg() and theme.WBg() helpers. Per-line Place() fills trailing whitespace. This ensures cream bg (#fdf6ef) on every pixel of every line, and warm bg (#f8f0e6) consistently across the cursor row. Also fixes bottom bar overflow on narrow terminals with adaptive keybindings.
446 lines
13 KiB
Go
446 lines
13 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 for the right-side columns.
|
||
const (
|
||
colSize = 10 // right-aligned file size
|
||
colFormat = 14 // "< webp >" centered
|
||
colStatus = 14 // "idle" / "converting..." / "done"
|
||
)
|
||
|
||
// nameWidth returns the flexible Name column width (everything left of Size).
|
||
func nameWidth(termW int) int {
|
||
// prefix(6) + name(flex) + gap(2) + size(10) + gap(2) + fmt(14) + gap(2) + status(14)
|
||
w := termW - 6 - 2 - colSize - 2 - colFormat - 2 - colStatus
|
||
if w < 20 {
|
||
w = 20
|
||
}
|
||
return w
|
||
}
|
||
|
||
// bg wraps a line so it fills the full terminal width with cream bg.
|
||
func (m Model) bg(line string) string {
|
||
return lipgloss.Place(m.width, 1, lipgloss.Left, lipgloss.Top, line,
|
||
lipgloss.WithWhitespaceStyle(theme.ScreenStyle))
|
||
}
|
||
|
||
// wbg wraps a line so it fills the full terminal width with warm bg (cursor row).
|
||
func (m Model) wbg(line string) string {
|
||
return lipgloss.Place(m.width, 1, lipgloss.Left, lipgloss.Top, line,
|
||
lipgloss.WithWhitespaceStyle(theme.WarmWhitespace))
|
||
}
|
||
|
||
// View renders the full-screen TUI.
|
||
func (m Model) View() string {
|
||
if m.width == 0 || m.height == 0 {
|
||
return "Loading..."
|
||
}
|
||
|
||
var top []string // content at the top
|
||
var bottom []string // content pinned to the bottom
|
||
|
||
top = append(top, m.bg(m.renderTitleBar()))
|
||
top = append(top, m.bg(m.renderDivider()))
|
||
|
||
switch m.state {
|
||
case stateFileList:
|
||
top = append(top, m.bg(""))
|
||
top = append(top, m.bg(m.renderColumnHeader()))
|
||
top = append(top, m.bg(""))
|
||
top = append(top, m.renderFileRows()...) // rows already have bg/wbg applied
|
||
|
||
bottom = append(bottom, m.bg(m.renderDivider()))
|
||
bottom = append(bottom, m.bg(m.renderBottomBar()))
|
||
|
||
case stateConverting:
|
||
for _, line := range strings.Split(m.renderConverting(), "\n") {
|
||
top = append(top, m.bg(line))
|
||
}
|
||
|
||
case stateResults:
|
||
for _, line := range strings.Split(m.renderResults(), "\n") {
|
||
top = append(top, m.bg(line))
|
||
}
|
||
bottom = append(bottom, m.bg(m.renderDivider()))
|
||
bottom = append(bottom, m.bg(m.renderResultsFooter()))
|
||
}
|
||
|
||
if m.showHelp {
|
||
for _, line := range strings.Split(m.renderHelp(), "\n") {
|
||
top = append(top, m.bg(line))
|
||
}
|
||
}
|
||
|
||
// Assemble: top lines + blank fill + bottom lines
|
||
totalUsed := len(top) + len(bottom)
|
||
fill := m.height - totalUsed
|
||
if fill < 0 {
|
||
fill = 0
|
||
}
|
||
|
||
blankLine := m.bg("")
|
||
|
||
var all []string
|
||
all = append(all, top...)
|
||
for i := 0; i < fill; i++ {
|
||
all = append(all, blankLine)
|
||
}
|
||
all = append(all, bottom...)
|
||
|
||
// Truncate to terminal height
|
||
if len(all) > m.height {
|
||
all = all[:m.height]
|
||
}
|
||
|
||
return strings.Join(all, "\n")
|
||
}
|
||
|
||
// ─── Title bar ───────────────────────────────────────────────
|
||
|
||
func (m Model) renderTitleBar() string {
|
||
title := theme.Bg(theme.Logo).Render("transmute")
|
||
selected := 0
|
||
for _, f := range m.files {
|
||
if f.selected {
|
||
selected++
|
||
}
|
||
}
|
||
info := theme.Bg(theme.Breadcrumb).Render(fmt.Sprintf(" %d files · %d selected", len(m.files), selected))
|
||
|
||
left := theme.BgStr(" ") + title + info
|
||
right := theme.Bg(theme.Help).Render("? help") + theme.BgStr(" ")
|
||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||
if gap < 1 {
|
||
gap = 1
|
||
}
|
||
return left + theme.BgStr(strings.Repeat(" ", gap)) + right
|
||
}
|
||
|
||
// ─── Divider ─────────────────────────────────────────────────
|
||
|
||
func (m Model) renderDivider() string {
|
||
w := m.width - 4
|
||
if w < 10 {
|
||
w = 10
|
||
}
|
||
return theme.BgStr(" ") + theme.Bg(theme.Divider).Render(strings.Repeat("─", w)) + theme.BgStr(" ")
|
||
}
|
||
|
||
// ─── Column header ───────────────────────────────────────────
|
||
|
||
func (m Model) renderColumnHeader() string {
|
||
nw := nameWidth(m.width)
|
||
prefix := theme.BgStr(strings.Repeat(" ", 6)) // cursor(3) + check(3)
|
||
|
||
nameHdr := theme.Bg(theme.Breadcrumb).Copy().Width(nw).Render("Name")
|
||
sizeHdr := theme.Bg(theme.Breadcrumb).Copy().Width(colSize).Align(lipgloss.Right).Render("Size")
|
||
fmtHdr := theme.Bg(theme.Breadcrumb).Copy().Width(colFormat).Align(lipgloss.Center).Render("Convert to")
|
||
statHdr := theme.Bg(theme.Breadcrumb).Copy().Width(colStatus).Align(lipgloss.Center).Render("Status")
|
||
|
||
return prefix + nameHdr + theme.BgStr(" ") + sizeHdr + theme.BgStr(" ") + fmtHdr + theme.BgStr(" ") + statHdr
|
||
}
|
||
|
||
// ─── File rows ───────────────────────────────────────────────
|
||
|
||
func (m Model) renderFileRows() []string {
|
||
if len(m.files) == 0 {
|
||
msg := theme.Bg(theme.Help).Render(" No supported files found. Pass file paths or glob patterns as arguments.")
|
||
return []string{m.bg(""), m.bg(msg), m.bg("")}
|
||
}
|
||
|
||
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++ {
|
||
rows = append(rows, m.renderFileRow(i))
|
||
}
|
||
|
||
if len(m.files) > maxVisible {
|
||
rows = append(rows, m.bg(theme.Bg(theme.Help).Render(fmt.Sprintf(
|
||
" showing %d–%d of %d", m.scroll+1, end, len(m.files)))))
|
||
}
|
||
|
||
return rows
|
||
}
|
||
|
||
func (m Model) renderFileRow(idx int) string {
|
||
f := m.files[idx]
|
||
isCursor := idx == m.cursor
|
||
nw := nameWidth(m.width)
|
||
|
||
// Pick background helpers based on cursor state
|
||
bgStr := theme.BgStr
|
||
bgS := theme.Bg
|
||
lineBg := m.bg
|
||
if isCursor {
|
||
bgStr = theme.WBgStr
|
||
bgS = theme.WBg
|
||
lineBg = m.wbg
|
||
}
|
||
|
||
// Cursor indicator
|
||
cursor := bgStr(" ")
|
||
if isCursor {
|
||
cursor = bgStr(" ") + bgS(theme.Selected).Render(">") + bgStr(" ")
|
||
}
|
||
|
||
// Selection dot
|
||
var check string
|
||
if f.selected {
|
||
check = bgS(theme.StatusDone).Render("●") + bgStr(" ")
|
||
} else {
|
||
check = bgS(theme.Breadcrumb).Render("○") + bgStr(" ")
|
||
}
|
||
|
||
// Icon + ext badge + filename
|
||
icon := detect.CategoryIcon(f.category)
|
||
catColor := theme.CategoryColor(string(f.category))
|
||
extBadge := bgS(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] + "..."
|
||
}
|
||
|
||
nameStyle := theme.FileName
|
||
if isCursor {
|
||
nameStyle = nameStyle.Copy().Bold(true)
|
||
}
|
||
|
||
nameContent := bgStr(icon+" ") + extBadge + bgStr(" ") + bgS(nameStyle).Render(nameText)
|
||
nameCell := bgS(lipgloss.NewStyle()).Copy().Width(nw).MaxWidth(nw).Render(nameContent)
|
||
|
||
// Size
|
||
sizeCell := bgS(theme.FileSize).Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
|
||
|
||
// Format selector
|
||
fmtStr := renderFormatSelector(f, isCursor, bgStr, bgS)
|
||
fmtCell := bgS(lipgloss.NewStyle()).Copy().Width(colFormat).Align(lipgloss.Center).Render(fmtStr)
|
||
|
||
// Status
|
||
var statusStr string
|
||
switch f.status {
|
||
case "idle":
|
||
statusStr = bgS(theme.StatusIdle).Render("idle")
|
||
case "converting":
|
||
statusStr = bgS(theme.StatusConverting).Render("converting...")
|
||
case "done":
|
||
statusStr = bgS(theme.StatusDone).Render("done")
|
||
case "error":
|
||
statusStr = bgS(theme.StatusError).Render("error")
|
||
}
|
||
statusCell := bgS(lipgloss.NewStyle()).Copy().Width(colStatus).Align(lipgloss.Center).Render(statusStr)
|
||
|
||
row := cursor + check + nameCell + bgStr(" ") + sizeCell + bgStr(" ") + fmtCell + bgStr(" ") + statusCell
|
||
|
||
return lineBg(row)
|
||
}
|
||
|
||
func renderFormatSelector(f fileEntry, active bool, bgStr func(string) string, bgS func(lipgloss.Style) lipgloss.Style) string {
|
||
if len(f.formats) == 0 {
|
||
return bgS(theme.Help).Render("—")
|
||
}
|
||
|
||
left := bgStr(" ")
|
||
right := bgStr(" ")
|
||
if active && f.formatIdx > 0 {
|
||
left = bgS(theme.Help).Render("< ")
|
||
}
|
||
if active && f.formatIdx < len(f.formats)-1 {
|
||
right = bgS(theme.Help).Render(" >")
|
||
}
|
||
|
||
var middle string
|
||
if active {
|
||
middle = bgS(theme.Selected).Render(f.targetFormat)
|
||
} else {
|
||
middle = bgS(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 = theme.BgStr(" ") + theme.ButtonPrimary.Render(fmt.Sprintf(" Convert %d files [c] ", selected))
|
||
} else {
|
||
left = theme.BgStr(" ") + theme.Bg(theme.Help).Render("Select files to convert")
|
||
}
|
||
|
||
// Adaptive keybindings: full or compact based on available space
|
||
leftW := lipgloss.Width(left)
|
||
fullHelp := "up/down navigate left/right format space select a all q quit"
|
||
shortHelp := "↑↓ nav ←→ fmt space sel a all q quit"
|
||
|
||
helpText := fullHelp
|
||
rightW := len(helpText) + 4 // 2 padding each side
|
||
if leftW+rightW > m.width {
|
||
helpText = shortHelp
|
||
}
|
||
|
||
right := theme.Bg(theme.Help).Render(helpText) + theme.BgStr(" ")
|
||
|
||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||
if gap < 1 {
|
||
gap = 1
|
||
}
|
||
return left + theme.BgStr(strings.Repeat(" ", gap)) + right
|
||
}
|
||
|
||
// ─── Converting view ─────────────────────────────────────────
|
||
|
||
func (m Model) renderConverting() string {
|
||
elapsed := time.Since(m.startTime).Round(time.Millisecond)
|
||
|
||
header := theme.Bg(theme.StatusConverting).Render(fmt.Sprintf(
|
||
" Converting... %d/%d (%s)", m.converted, m.totalToConv, elapsed))
|
||
|
||
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 := theme.BgStr(" ") +
|
||
theme.Bg(theme.ProgressFilled).Render(strings.Repeat("█", filled)) +
|
||
theme.Bg(theme.ProgressEmpty).Render(strings.Repeat("░", barWidth-filled))
|
||
|
||
var current []string
|
||
for _, f := range m.files {
|
||
if f.status == "converting" {
|
||
current = append(current, fmt.Sprintf(" %s → %s", f.name, f.targetFormat))
|
||
}
|
||
}
|
||
currentStr := theme.Bg(theme.Help).Render(strings.Join(current, "\n"))
|
||
|
||
return lipgloss.JoinVertical(lipgloss.Left,
|
||
"", header, bar, "", currentStr, "",
|
||
theme.Bg(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.Bg(theme.StatusDone).Render(fmt.Sprintf(
|
||
" Conversion complete! %d succeeded", successCount))
|
||
if errorCount > 0 {
|
||
summary += theme.Bg(theme.StatusError).Render(fmt.Sprintf(", %d failed", errorCount))
|
||
}
|
||
summary += theme.Bg(theme.Help).Render(fmt.Sprintf(" (%s)", elapsed))
|
||
|
||
rows = append(rows, "", summary, "")
|
||
|
||
for _, f := range m.files {
|
||
if !f.selected {
|
||
continue
|
||
}
|
||
icon := detect.CategoryIcon(f.category)
|
||
catColor := theme.CategoryColor(string(f.category))
|
||
extBadge := theme.Bg(theme.ExtBadge(catColor)).Render(strings.ToUpper(f.ext))
|
||
|
||
switch f.status {
|
||
case "done":
|
||
rows = append(rows,
|
||
theme.BgStr(" ")+theme.Bg(theme.StatusDone).Render("✓")+" "+
|
||
theme.BgStr(icon+" ")+extBadge+theme.BgStr(" ")+
|
||
theme.Bg(theme.FileName).Render(f.name)+
|
||
theme.Bg(theme.Help).Render(" → ")+
|
||
theme.Bg(theme.BreadcrumbActive).Render(f.outputPath))
|
||
case "error":
|
||
rows = append(rows,
|
||
theme.BgStr(" ")+theme.Bg(theme.StatusError).Render("✗")+" "+
|
||
theme.BgStr(icon+" ")+extBadge+theme.BgStr(" ")+
|
||
theme.Bg(theme.FileName).Render(f.name)+
|
||
theme.Bg(theme.Help).Render(" — ")+
|
||
theme.Bg(theme.StatusError).Render(f.error))
|
||
}
|
||
}
|
||
|
||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||
}
|
||
|
||
func (m Model) renderResultsFooter() string {
|
||
return theme.Bg(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.Bg(theme.BreadcrumbActive).Render(" Keyboard Shortcuts"))
|
||
lines = append(lines, "")
|
||
|
||
for _, k := range keys {
|
||
lines = append(lines, fmt.Sprintf(" %s %s",
|
||
theme.Bg(theme.Selected).Copy().Width(18).Render(k.key),
|
||
theme.Bg(theme.Help).Render(k.desc)))
|
||
}
|
||
lines = append(lines, "")
|
||
|
||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||
}
|