Files
Transmute/cli/internal/tui/views.go
T
noah ed25ffa533 fix: per-segment bg coloring for full-screen cream + warm cursor highlight
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.
2026-03-09 23:34:52 +01:00

446 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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...)
}