feat: add CLI with TUI, self-update, install script, and terminal section on landing page
- Full-screen Bubble Tea TUI with cream background fill using PadLine/FillBlankLines - Self-update command (--update) pulling from GitHub releases - install.sh for curl one-liner installation - Terminal Lovers section on web landing page with install command and CLI features - All 7 format categories, glob/directory batch support, auto-download ffmpeg
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// KeyMap defines key bindings for the TUI.
|
||||
type KeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Enter key.Binding
|
||||
Space key.Binding
|
||||
Tab key.Binding
|
||||
Delete key.Binding
|
||||
SelectAll key.Binding
|
||||
Convert key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
Back key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default key bindings.
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k"),
|
||||
key.WithHelp("↑/k", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j"),
|
||||
key.WithHelp("↓/j", "down"),
|
||||
),
|
||||
Left: key.NewBinding(
|
||||
key.WithKeys("left", "h"),
|
||||
key.WithHelp("←/h", "prev format"),
|
||||
),
|
||||
Right: key.NewBinding(
|
||||
key.WithKeys("right", "l"),
|
||||
key.WithHelp("→/l", "next format"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "confirm"),
|
||||
),
|
||||
Space: key.NewBinding(
|
||||
key.WithKeys(" "),
|
||||
key.WithHelp("space", "toggle select"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "cycle format"),
|
||||
),
|
||||
Delete: key.NewBinding(
|
||||
key.WithKeys("d", "delete", "backspace"),
|
||||
key.WithHelp("d", "remove file"),
|
||||
),
|
||||
SelectAll: key.NewBinding(
|
||||
key.WithKeys("a"),
|
||||
key.WithHelp("a", "select all"),
|
||||
),
|
||||
Convert: key.NewBinding(
|
||||
key.WithKeys("c"),
|
||||
key.WithHelp("c", "convert"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "help"),
|
||||
),
|
||||
Back: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "back"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/noauf/transmute-cli/internal/converter"
|
||||
"github.com/noauf/transmute-cli/internal/detect"
|
||||
)
|
||||
|
||||
// ─── State machine ───────────────────────────────────────────
|
||||
|
||||
type state int
|
||||
|
||||
const (
|
||||
stateFileList state = iota // Browsing/selecting files
|
||||
stateConverting // Conversion in progress
|
||||
stateResults // Showing results
|
||||
)
|
||||
|
||||
// ─── File entry ──────────────────────────────────────────────
|
||||
|
||||
type fileEntry struct {
|
||||
path string
|
||||
name string
|
||||
ext string
|
||||
size int64
|
||||
category detect.FileCategory
|
||||
selected bool
|
||||
targetFormat string
|
||||
formats []string
|
||||
formatIdx int
|
||||
status string // "idle", "converting", "done", "error"
|
||||
error string
|
||||
outputPath string
|
||||
}
|
||||
|
||||
// ─── Messages ────────────────────────────────────────────────
|
||||
|
||||
type conversionDoneMsg struct {
|
||||
index int
|
||||
result converter.Result
|
||||
}
|
||||
|
||||
type conversionStartMsg struct {
|
||||
index int
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
// ─── Model ───────────────────────────────────────────────────
|
||||
|
||||
type Model struct {
|
||||
files []fileEntry
|
||||
cursor int
|
||||
state state
|
||||
keys KeyMap
|
||||
width int
|
||||
height int
|
||||
outputDir string
|
||||
showHelp bool
|
||||
scroll int // scroll offset for file list
|
||||
|
||||
// Progress tracking
|
||||
converting int
|
||||
converted int
|
||||
totalToConv int
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// New creates a new TUI model from a list of file paths and an output directory.
|
||||
func New(paths []string, outputDir string) Model {
|
||||
var files []fileEntry
|
||||
|
||||
for _, p := range paths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
// Expand directory
|
||||
dirFiles := expandDir(p)
|
||||
files = append(files, dirFiles...)
|
||||
} else {
|
||||
entry := makeFileEntry(p, info)
|
||||
if entry != nil {
|
||||
files = append(files, *entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Model{
|
||||
files: files,
|
||||
cursor: 0,
|
||||
state: stateFileList,
|
||||
keys: DefaultKeyMap(),
|
||||
showHelp: false,
|
||||
outputDir: outputDir,
|
||||
}
|
||||
}
|
||||
|
||||
func expandDir(dir string) []fileEntry {
|
||||
var files []fileEntry
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return files
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, e.Name())
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entry := makeFileEntry(p, info)
|
||||
if entry != nil {
|
||||
files = append(files, *entry)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func makeFileEntry(path string, info os.FileInfo) *fileEntry {
|
||||
ext := strings.TrimPrefix(filepath.Ext(path), ".")
|
||||
ext = strings.ToLower(ext)
|
||||
|
||||
if !detect.IsSupported(ext) {
|
||||
return nil
|
||||
}
|
||||
|
||||
formats := detect.GetAvailableFormats(ext)
|
||||
if len(formats) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the smart default target (matches web app defaults)
|
||||
defaultTarget := detect.GetDefaultTarget(ext)
|
||||
defaultIdx := 0
|
||||
for i, f := range formats {
|
||||
if f == defaultTarget {
|
||||
defaultIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &fileEntry{
|
||||
path: path,
|
||||
name: info.Name(),
|
||||
ext: ext,
|
||||
size: info.Size(),
|
||||
category: detect.DetectCategory(ext),
|
||||
selected: true, // Select all by default
|
||||
targetFormat: defaultTarget,
|
||||
formats: formats,
|
||||
formatIdx: defaultIdx,
|
||||
status: "idle",
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Update ──────────────────────────────────────────────────
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
|
||||
case conversionDoneMsg:
|
||||
if msg.index >= 0 && msg.index < len(m.files) {
|
||||
if msg.result.Err != nil {
|
||||
m.files[msg.index].status = "error"
|
||||
m.files[msg.index].error = msg.result.Err.Error()
|
||||
} else {
|
||||
m.files[msg.index].status = "done"
|
||||
m.files[msg.index].outputPath = msg.result.OutputPath
|
||||
}
|
||||
m.converted++
|
||||
}
|
||||
|
||||
// Check if all done
|
||||
if m.converted >= m.totalToConv {
|
||||
m.state = stateResults
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Start next conversion
|
||||
return m, m.convertNext()
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch m.state {
|
||||
case stateFileList:
|
||||
return m.handleFileListKey(msg)
|
||||
case stateConverting:
|
||||
// Only allow quit during conversion
|
||||
if key.Matches(msg, m.keys.Quit) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case stateResults:
|
||||
return m.handleResultsKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.ensureVisible()
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.cursor < len(m.files)-1 {
|
||||
m.cursor++
|
||||
m.ensureVisible()
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Space):
|
||||
if len(m.files) > 0 {
|
||||
m.files[m.cursor].selected = !m.files[m.cursor].selected
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Left):
|
||||
if len(m.files) > 0 {
|
||||
f := &m.files[m.cursor]
|
||||
if f.formatIdx > 0 {
|
||||
f.formatIdx--
|
||||
f.targetFormat = f.formats[f.formatIdx]
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Right), key.Matches(msg, m.keys.Tab):
|
||||
if len(m.files) > 0 {
|
||||
f := &m.files[m.cursor]
|
||||
if f.formatIdx < len(f.formats)-1 {
|
||||
f.formatIdx++
|
||||
f.targetFormat = f.formats[f.formatIdx]
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.SelectAll):
|
||||
allSelected := true
|
||||
for _, f := range m.files {
|
||||
if !f.selected {
|
||||
allSelected = false
|
||||
break
|
||||
}
|
||||
}
|
||||
for i := range m.files {
|
||||
m.files[i].selected = !allSelected
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Delete):
|
||||
if len(m.files) > 0 {
|
||||
m.files = append(m.files[:m.cursor], m.files[m.cursor+1:]...)
|
||||
if m.cursor >= len(m.files) && m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Convert), key.Matches(msg, m.keys.Enter):
|
||||
return m.startConversion()
|
||||
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.showHelp = !m.showHelp
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Enter):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
// Go back to file list to convert more
|
||||
m.state = stateFileList
|
||||
for i := range m.files {
|
||||
if m.files[i].status == "done" || m.files[i].status == "error" {
|
||||
m.files[i].status = "idle"
|
||||
m.files[i].error = ""
|
||||
m.files[i].outputPath = ""
|
||||
}
|
||||
}
|
||||
m.converting = 0
|
||||
m.converted = 0
|
||||
m.totalToConv = 0
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.cursor < len(m.files)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ─── Conversion logic ────────────────────────────────────────
|
||||
|
||||
func (m Model) startConversion() (Model, tea.Cmd) {
|
||||
// Count selected files
|
||||
count := 0
|
||||
for _, f := range m.files {
|
||||
if f.selected {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.state = stateConverting
|
||||
m.totalToConv = count
|
||||
m.converted = 0
|
||||
m.converting = 0
|
||||
m.startTime = time.Now()
|
||||
|
||||
return m, m.convertNext()
|
||||
}
|
||||
|
||||
func (m Model) convertNext() tea.Cmd {
|
||||
// Find next file to convert
|
||||
for i := range m.files {
|
||||
if m.files[i].selected && m.files[i].status == "idle" {
|
||||
m.files[i].status = "converting"
|
||||
idx := i
|
||||
path := m.files[i].path
|
||||
target := m.files[i].targetFormat
|
||||
outDir := m.outputDir
|
||||
|
||||
return func() tea.Msg {
|
||||
result := converter.Convert(path, target, outDir)
|
||||
return conversionDoneMsg{index: idx, result: result}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) ensureVisible() {
|
||||
maxVisible := m.maxVisibleFiles()
|
||||
if m.cursor < m.scroll {
|
||||
m.scroll = m.cursor
|
||||
}
|
||||
if m.cursor >= m.scroll+maxVisible {
|
||||
m.scroll = m.cursor - maxVisible + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) maxVisibleFiles() int {
|
||||
available := m.height - 12 // Reserve space for header, footer, borders
|
||||
if available < 3 {
|
||||
return 3
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
func formatSize(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
)
|
||||
switch {
|
||||
case bytes >= GB:
|
||||
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB))
|
||||
case bytes >= MB:
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
|
||||
case bytes >= KB:
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/noauf/transmute-cli/internal/detect"
|
||||
"github.com/noauf/transmute-cli/internal/theme"
|
||||
)
|
||||
|
||||
// View renders the entire TUI, filling the full terminal.
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
var sections []string
|
||||
|
||||
sections = append(sections, m.renderTitleBar())
|
||||
sections = append(sections, m.renderDivider())
|
||||
|
||||
switch m.state {
|
||||
case stateFileList:
|
||||
sections = append(sections, m.renderFileList())
|
||||
sections = append(sections, m.renderDivider())
|
||||
sections = append(sections, m.renderBottomBar())
|
||||
case stateConverting:
|
||||
sections = append(sections, m.renderConverting())
|
||||
case stateResults:
|
||||
sections = append(sections, m.renderResults())
|
||||
sections = append(sections, m.renderDivider())
|
||||
sections = append(sections, m.renderResultsFooter())
|
||||
}
|
||||
|
||||
if m.showHelp {
|
||||
sections = append(sections, m.renderHelp())
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||
|
||||
// Split into individual lines and pad each to full width with background
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = theme.PadLine(line, m.width)
|
||||
}
|
||||
|
||||
// Fill remaining vertical space with background-colored blank lines
|
||||
remaining := m.height - len(lines)
|
||||
if remaining > 0 {
|
||||
fill := theme.FillBlankLines(remaining, m.width)
|
||||
return strings.Join(lines, "\n") + "\n" + fill
|
||||
}
|
||||
|
||||
// Truncate if content exceeds 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 · %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)
|
||||
}
|
||||
|
||||
return left + padding + rightContent
|
||||
}
|
||||
|
||||
// ─── Divider ─────────────────────────────────────────────────
|
||||
|
||||
func (m Model) renderDivider() string {
|
||||
w := m.width
|
||||
if w <= 0 {
|
||||
w = 60
|
||||
}
|
||||
return theme.Divider.Render(strings.Repeat("─", w))
|
||||
}
|
||||
|
||||
// ─── File list ───────────────────────────────────────────────
|
||||
|
||||
func (m Model) renderFileList() string {
|
||||
if len(m.files) == 0 {
|
||||
empty := lipgloss.NewStyle().
|
||||
Foreground(theme.Light).
|
||||
Italic(true).
|
||||
Padding(2, 4).
|
||||
Render("No supported files found. Pass file paths or glob patterns as arguments.")
|
||||
return empty
|
||||
}
|
||||
|
||||
// Column header
|
||||
header := renderColumnHeader(m.width)
|
||||
|
||||
maxVisible := m.maxVisibleFiles()
|
||||
end := m.scroll + maxVisible
|
||||
if end > len(m.files) {
|
||||
end = len(m.files)
|
||||
}
|
||||
|
||||
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
|
||||
if rendered < maxVisible {
|
||||
emptyRow := strings.Repeat(" ", m.width)
|
||||
for i := rendered; i < maxVisible; i++ {
|
||||
rows = append(rows, emptyRow)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)))
|
||||
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
|
||||
|
||||
nameW := 30
|
||||
sizeW := 10
|
||||
formatW := 20
|
||||
statusW := 12
|
||||
|
||||
if m.width > 100 {
|
||||
nameW = m.width - sizeW - formatW - statusW - 12
|
||||
}
|
||||
|
||||
// Cursor + selection indicator
|
||||
prefix := " "
|
||||
if isCursor {
|
||||
prefix = theme.Selected.Render("> ")
|
||||
}
|
||||
|
||||
// Checkbox
|
||||
check := "○"
|
||||
if f.selected {
|
||||
check = theme.StatusDone.Render("●")
|
||||
}
|
||||
|
||||
// Category icon + file name
|
||||
catColor := theme.CategoryColor(string(f.category))
|
||||
icon := detect.CategoryIcon(f.category)
|
||||
nameText := f.name
|
||||
if len(nameText) > nameW-8 {
|
||||
nameText = nameText[:nameW-11] + "..."
|
||||
}
|
||||
|
||||
var nameStyle lipgloss.Style
|
||||
if isCursor {
|
||||
nameStyle = theme.FileName.Copy().Bold(true)
|
||||
} else {
|
||||
nameStyle = theme.FileName
|
||||
}
|
||||
|
||||
nameCol := lipgloss.NewStyle().Width(nameW).Render(
|
||||
prefix + check + " " + icon + " " + theme.ExtBadge(catColor).Render(strings.ToUpper(f.ext)) + " " + nameStyle.Render(nameText))
|
||||
|
||||
// Size
|
||||
sizeCol := theme.FileSize.Copy().Width(sizeW).Align(lipgloss.Right).Render(formatSize(f.size))
|
||||
|
||||
// Format selector
|
||||
formatStr := renderFormatSelector(f, isCursor)
|
||||
formatCol := lipgloss.NewStyle().Width(formatW).Align(lipgloss.Center).Render(formatStr)
|
||||
|
||||
// Status
|
||||
var statusStr string
|
||||
switch f.status {
|
||||
case "idle":
|
||||
statusStr = theme.StatusIdle.Render("idle")
|
||||
case "converting":
|
||||
statusStr = theme.StatusConverting.Render("converting...")
|
||||
case "done":
|
||||
statusStr = theme.StatusDone.Render("done")
|
||||
case "error":
|
||||
statusStr = theme.StatusError.Render("error")
|
||||
}
|
||||
statusCol := lipgloss.NewStyle().Width(statusW).Align(lipgloss.Center).Render(statusStr)
|
||||
|
||||
return nameCol + sizeCol + " " + formatCol + " " + statusCol
|
||||
}
|
||||
|
||||
func renderFormatSelector(f fileEntry, active bool) string {
|
||||
if len(f.formats) == 0 {
|
||||
return theme.Help.Render("—")
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if active && f.formatIdx > 0 {
|
||||
parts = append(parts, theme.Help.Render("< "))
|
||||
} else {
|
||||
parts = append(parts, " ")
|
||||
}
|
||||
|
||||
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, " ")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// ─── 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.ButtonPrimary.Render(fmt.Sprintf(" Convert %d files [c] ", selected))
|
||||
} else {
|
||||
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")
|
||||
|
||||
padding := ""
|
||||
totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
|
||||
if m.width > totalWidth {
|
||||
padding = strings.Repeat(" ", m.width-totalWidth)
|
||||
}
|
||||
|
||||
return left + padding + 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 := " " +
|
||||
theme.ProgressFilled.Render(strings.Repeat("█", filled)) +
|
||||
theme.ProgressEmpty.Render(strings.Repeat("░", 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))
|
||||
}
|
||||
}
|
||||
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(" done ")+
|
||||
theme.FileName.Render(f.name)+
|
||||
theme.Help.Render(" -> ")+
|
||||
theme.BreadcrumbActive.Render(f.outputPath))
|
||||
case "error":
|
||||
rows = append(rows, theme.StatusError.Render(" fail ")+
|
||||
theme.FileName.Render(f.name)+
|
||||
theme.Help.Render(" -- ")+
|
||||
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, fmt.Sprintf(" %s %s",
|
||||
theme.Selected.Copy().Width(18).Render(k.key),
|
||||
theme.Help.Render(k.desc)))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
Reference in New Issue
Block a user