Files
Transmute/cli/internal/tui/views.go
T
noah 82a4123f84 feat: v0.1.5 - no default selection, stay on file list after convert, improved help menu
- Files no longer selected by default (user must select manually)
- After conversion, stays on file list (no more stateResults/esc to reconvert)
- x key resets done file to idle (delete output + reconvert in one)
- p key opens output if done, otherwise input file
- Help menu now has cream background, title bar, and divider to blend in smoothly
- Removed stateResults entirely - simplified to single state
2026-03-11 10:32:23 +01:00

408 lines
12 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()))
}
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++
}
}
var infoText string
isConverting := m.totalToConv > 0 && m.converted < m.totalToConv
if isConverting {
infoText = fmt.Sprintf(" %d files · converting %d/%d", len(m.files), m.converted, m.totalToConv)
} else {
infoText = fmt.Sprintf(" %d files · %d selected", len(m.files), selected)
}
info := theme.Bg(theme.Breadcrumb).Render(infoText)
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 — hide arrows during conversion/results (keys are blocked)
isConverting := m.totalToConv > 0 && m.converted < m.totalToConv
showArrows := isCursor && !isConverting && m.state == stateFileList
fmtStr := renderFormatSelector(f, showArrows, 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")
case "deleted":
statusStr = bgS(theme.StatusError).Render("deleted")
}
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 {
isConverting := m.totalToConv > 0 && m.converted < m.totalToConv
if isConverting {
// Show progress inline
elapsed := time.Since(m.startTime).Round(time.Millisecond)
left := theme.BgStr(" ") +
theme.Bg(theme.StatusConverting).Render(
fmt.Sprintf(" Converting %d/%d ", m.converted, m.totalToConv)) +
theme.Bg(theme.Help).Render(fmt.Sprintf(" %s", elapsed))
right := theme.Bg(theme.Help).Render("q quit") + theme.BgStr(" ")
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return left + theme.BgStr(strings.Repeat(" ", gap)) + right
}
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 p preview a all q quit"
shortHelp := "↑↓ nav ←→ fmt spc sel p preview 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
}
// renderResultsBar shows a summary after all conversions are done.
func (m Model) renderResultsBar() 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)
left := theme.BgStr(" ") +
theme.Bg(theme.StatusDone).Render(fmt.Sprintf(" %d converted ", successCount))
if errorCount > 0 {
left += theme.BgStr(" ") + theme.Bg(theme.StatusError).Render(fmt.Sprintf(" %d failed ", errorCount))
}
left += theme.Bg(theme.Help).Render(fmt.Sprintf(" %s", elapsed))
right := theme.Bg(theme.Help).Render("p preview x delete enter quit esc convert more") + theme.BgStr(" ")
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return left + theme.BgStr(strings.Repeat(" ", gap)) + right
}
// ─── Help overlay ────────────────────────────────────────────
func (m Model) renderHelp() string {
keys := []struct {
key string
desc string
}{
{"↑/↓ or j/k", "Navigate files"},
{"←/→ or h/l", "Change target format"},
{"space", "Toggle selection"},
{"a", "Select / deselect all"},
{"p", "Preview / open file"},
{"d", "Remove from list"},
{"x", "Delete output"},
{"c or enter", "Convert selected"},
{"? or q", "Close / quit"},
}
// Title bar
var lines []string
title := theme.Bg(theme.Logo).Render(" Keyboard Shortcuts ")
divider := theme.Bg(theme.Divider).Render(strings.Repeat("─", m.width-4))
lines = append(lines, m.bg(""))
lines = append(lines, m.bg(theme.BgStr(" ")+title+theme.BgStr(strings.Repeat(" ", m.width-4-lipgloss.Width(title)-2))))
lines = append(lines, m.bg(divider))
for _, k := range keys {
keyStr := theme.Bg(theme.Selected).Copy().Width(16).Render(k.key)
descStr := theme.Bg(theme.Help).Render(" " + k.desc + " ")
line := m.bg(keyStr + descStr)
lines = append(lines, line)
}
lines = append(lines, m.bg(divider))
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}