feat: inline conversion with status updates, cwebp support, v0.1.4
- Conversion now happens inline on the file list — status column updates from idle → converting... → done/error in place (no separate screen) - Fixed value-receiver bug in convertNext() that prevented 'converting' status from being displayed - Added cwebp fallback for PNG/JPEG → WebP (ffmpeg webp encoder often missing on macOS) - Format selector arrows hidden during conversion/results states - Simplified to 2 states: stateFileList + stateResults - Added tests for conversion state machine and view rendering
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
package converter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertImage(t *testing.T) {
|
||||||
|
// Find a test PNG file
|
||||||
|
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
|
||||||
|
if _, err := os.Stat(testFile); err != nil {
|
||||||
|
t.Skipf("test file not found: %s", testFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
outDir := t.TempDir()
|
||||||
|
result := Convert(testFile, "webp", outDir)
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("conversion failed: %v", result.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(result.OutputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("output file not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Converted %s -> %s (%d bytes)", testFile, result.OutputPath, info.Size())
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/image/bmp"
|
"golang.org/x/image/bmp"
|
||||||
@@ -88,6 +89,17 @@ func tryDecodeImage(f *os.File, path string) (image.Image, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func convertImageViaFFmpeg(inputPath, outputPath, format string) error {
|
func convertImageViaFFmpeg(inputPath, outputPath, format string) error {
|
||||||
|
// For WebP: prefer cwebp (from libwebp-tools) which is widely available
|
||||||
|
if format == "webp" {
|
||||||
|
if cwebpPath, err := exec.LookPath("cwebp"); err == nil {
|
||||||
|
cmd := exec.Command(cwebpPath, "-q", "90", inputPath, "-o", outputPath)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("cwebp error: %w\n%s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{"-y", "-i", inputPath}
|
args := []string{"-y", "-i", inputPath}
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
|
|||||||
+33
-31
@@ -19,9 +19,8 @@ import (
|
|||||||
type state int
|
type state int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateFileList state = iota // Browsing/selecting files
|
stateFileList state = iota // Browsing/selecting files (also used during conversion)
|
||||||
stateConverting // Conversion in progress
|
stateResults // All conversions finished — still shows file list
|
||||||
stateResults // Showing results
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── File entry ──────────────────────────────────────────────
|
// ─── File entry ──────────────────────────────────────────────
|
||||||
@@ -199,7 +198,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start next conversion
|
// Start next conversion
|
||||||
return m, m.convertNext()
|
m, cmd := m.convertNext()
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
return m.handleKey(msg)
|
return m.handleKey(msg)
|
||||||
@@ -212,12 +212,6 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch m.state {
|
switch m.state {
|
||||||
case stateFileList:
|
case stateFileList:
|
||||||
return m.handleFileListKey(msg)
|
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:
|
case stateResults:
|
||||||
return m.handleResultsKey(msg)
|
return m.handleResultsKey(msg)
|
||||||
}
|
}
|
||||||
@@ -225,6 +219,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
isConverting := m.totalToConv > 0 && m.converted < m.totalToConv
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Quit):
|
case key.Matches(msg, m.keys.Quit):
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -241,13 +237,17 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.ensureVisible()
|
m.ensureVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Help):
|
||||||
|
m.showHelp = !m.showHelp
|
||||||
|
|
||||||
|
// Everything below is blocked while converting
|
||||||
case key.Matches(msg, m.keys.Space):
|
case key.Matches(msg, m.keys.Space):
|
||||||
if len(m.files) > 0 {
|
if !isConverting && len(m.files) > 0 {
|
||||||
m.files[m.cursor].selected = !m.files[m.cursor].selected
|
m.files[m.cursor].selected = !m.files[m.cursor].selected
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Left):
|
case key.Matches(msg, m.keys.Left):
|
||||||
if len(m.files) > 0 {
|
if !isConverting && len(m.files) > 0 {
|
||||||
f := &m.files[m.cursor]
|
f := &m.files[m.cursor]
|
||||||
if f.formatIdx > 0 {
|
if f.formatIdx > 0 {
|
||||||
f.formatIdx--
|
f.formatIdx--
|
||||||
@@ -256,7 +256,7 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Right), key.Matches(msg, m.keys.Tab):
|
case key.Matches(msg, m.keys.Right), key.Matches(msg, m.keys.Tab):
|
||||||
if len(m.files) > 0 {
|
if !isConverting && len(m.files) > 0 {
|
||||||
f := &m.files[m.cursor]
|
f := &m.files[m.cursor]
|
||||||
if f.formatIdx < len(f.formats)-1 {
|
if f.formatIdx < len(f.formats)-1 {
|
||||||
f.formatIdx++
|
f.formatIdx++
|
||||||
@@ -265,19 +265,21 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.SelectAll):
|
case key.Matches(msg, m.keys.SelectAll):
|
||||||
allSelected := true
|
if !isConverting {
|
||||||
for _, f := range m.files {
|
allSelected := true
|
||||||
if !f.selected {
|
for _, f := range m.files {
|
||||||
allSelected = false
|
if !f.selected {
|
||||||
break
|
allSelected = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range m.files {
|
||||||
|
m.files[i].selected = !allSelected
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for i := range m.files {
|
|
||||||
m.files[i].selected = !allSelected
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Delete):
|
case key.Matches(msg, m.keys.Delete):
|
||||||
if len(m.files) > 0 {
|
if !isConverting && len(m.files) > 0 {
|
||||||
m.files = append(m.files[:m.cursor], m.files[m.cursor+1:]...)
|
m.files = append(m.files[:m.cursor], m.files[m.cursor+1:]...)
|
||||||
if m.cursor >= len(m.files) && m.cursor > 0 {
|
if m.cursor >= len(m.files) && m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
@@ -285,10 +287,9 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Convert), key.Matches(msg, m.keys.Enter):
|
case key.Matches(msg, m.keys.Convert), key.Matches(msg, m.keys.Enter):
|
||||||
return m.startConversion()
|
if !isConverting {
|
||||||
|
return m.startConversion()
|
||||||
case key.Matches(msg, m.keys.Help):
|
}
|
||||||
m.showHelp = !m.showHelp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -337,16 +338,17 @@ func (m Model) startConversion() (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m.state = stateConverting
|
// Stay on stateFileList — status column updates inline
|
||||||
m.totalToConv = count
|
m.totalToConv = count
|
||||||
m.converted = 0
|
m.converted = 0
|
||||||
m.converting = 0
|
m.converting = 0
|
||||||
m.startTime = time.Now()
|
m.startTime = time.Now()
|
||||||
|
|
||||||
return m, m.convertNext()
|
m, cmd := m.convertNext()
|
||||||
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) convertNext() tea.Cmd {
|
func (m Model) convertNext() (Model, tea.Cmd) {
|
||||||
// Find next file to convert
|
// Find next file to convert
|
||||||
for i := range m.files {
|
for i := range m.files {
|
||||||
if m.files[i].selected && m.files[i].status == "idle" {
|
if m.files[i].selected && m.files[i].status == "idle" {
|
||||||
@@ -356,13 +358,13 @@ func (m Model) convertNext() tea.Cmd {
|
|||||||
target := m.files[i].targetFormat
|
target := m.files[i].targetFormat
|
||||||
outDir := m.outputDir
|
outDir := m.outputDir
|
||||||
|
|
||||||
return func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
result := converter.Convert(path, target, outDir)
|
result := converter.Convert(path, target, outDir)
|
||||||
return conversionDoneMsg{index: idx, result: result}
|
return conversionDoneMsg{index: idx, result: result}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) ensureVisible() {
|
func (m *Model) ensureVisible() {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInlineConversionStateMachine(t *testing.T) {
|
||||||
|
// Find a test PNG file
|
||||||
|
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
|
||||||
|
if _, err := os.Stat(testFile); err != nil {
|
||||||
|
t.Skipf("test file not found: %s", testFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
outDir := t.TempDir()
|
||||||
|
m := New([]string{testFile}, outDir)
|
||||||
|
m.width = 80
|
||||||
|
m.height = 24
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
if len(m.files) != 1 {
|
||||||
|
t.Fatalf("expected 1 file, got %d", len(m.files))
|
||||||
|
}
|
||||||
|
if m.state != stateFileList {
|
||||||
|
t.Fatalf("expected stateFileList, got %d", m.state)
|
||||||
|
}
|
||||||
|
if m.files[0].status != "idle" {
|
||||||
|
t.Fatalf("expected idle status, got %s", m.files[0].status)
|
||||||
|
}
|
||||||
|
if !m.files[0].selected {
|
||||||
|
t.Fatal("expected file to be selected by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("File: %s -> %s (selected: %v)", m.files[0].name, m.files[0].targetFormat, m.files[0].selected)
|
||||||
|
|
||||||
|
// Simulate pressing 'c' to start conversion
|
||||||
|
newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
if m.state != stateFileList {
|
||||||
|
t.Fatalf("expected to stay on stateFileList during conversion, got %d", m.state)
|
||||||
|
}
|
||||||
|
if m.totalToConv != 1 {
|
||||||
|
t.Fatalf("expected totalToConv=1, got %d", m.totalToConv)
|
||||||
|
}
|
||||||
|
if m.files[0].status != "converting" {
|
||||||
|
t.Fatalf("expected 'converting' status after pressing c, got '%s'", m.files[0].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Status correctly set to 'converting' — inline update works!")
|
||||||
|
|
||||||
|
// Execute the conversion command
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected a conversion command, got nil")
|
||||||
|
}
|
||||||
|
msg := cmd()
|
||||||
|
|
||||||
|
// Process the result
|
||||||
|
newModel, _ = m.Update(msg)
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
if m.state != stateResults {
|
||||||
|
t.Fatalf("expected stateResults after all conversions done, got %d", m.state)
|
||||||
|
}
|
||||||
|
if m.files[0].status != "done" {
|
||||||
|
t.Fatalf("expected 'done' status, got '%s' (error: %s)", m.files[0].status, m.files[0].error)
|
||||||
|
}
|
||||||
|
if m.converted != 1 {
|
||||||
|
t.Fatalf("expected converted=1, got %d", m.converted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check output file exists
|
||||||
|
info, err := os.Stat(m.files[0].outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("output file not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Conversion complete: %s (%d bytes)", m.files[0].outputPath, info.Size())
|
||||||
|
|
||||||
|
// Test 'esc' to go back to file list
|
||||||
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyEscape})
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
if m.state != stateFileList {
|
||||||
|
t.Fatalf("expected stateFileList after esc, got %d", m.state)
|
||||||
|
}
|
||||||
|
if m.files[0].status != "idle" {
|
||||||
|
t.Fatalf("expected status reset to 'idle' after esc, got '%s'", m.files[0].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("State machine verified: idle -> converting -> done -> idle (via esc)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewRendersDuringConversion(t *testing.T) {
|
||||||
|
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
|
||||||
|
if _, err := os.Stat(testFile); err != nil {
|
||||||
|
t.Skipf("test file not found: %s", testFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := New([]string{testFile}, t.TempDir())
|
||||||
|
m.width = 80
|
||||||
|
m.height = 24
|
||||||
|
|
||||||
|
// Render initial state
|
||||||
|
view := m.View()
|
||||||
|
if view == "" {
|
||||||
|
t.Fatal("empty view")
|
||||||
|
}
|
||||||
|
if !containsStr(view, "idle") {
|
||||||
|
t.Error("initial view should contain 'idle' status")
|
||||||
|
}
|
||||||
|
if !containsStr(view, "Convert 1 files") {
|
||||||
|
t.Error("initial view should contain convert button")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start conversion
|
||||||
|
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
view = m.View()
|
||||||
|
if !containsStr(view, "converting") {
|
||||||
|
t.Error("view during conversion should contain 'converting' status")
|
||||||
|
}
|
||||||
|
if !containsStr(view, "Converting 0/1") {
|
||||||
|
t.Errorf("view during conversion should show progress, got:\n%s", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("View renders correctly during conversion")
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(haystack, needle string) bool {
|
||||||
|
return len(haystack) > 0 && len(needle) > 0 && contains(haystack, needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+52
-88
@@ -62,17 +62,14 @@ func (m Model) View() string {
|
|||||||
bottom = append(bottom, m.bg(m.renderDivider()))
|
bottom = append(bottom, m.bg(m.renderDivider()))
|
||||||
bottom = append(bottom, m.bg(m.renderBottomBar()))
|
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:
|
case stateResults:
|
||||||
for _, line := range strings.Split(m.renderResults(), "\n") {
|
top = append(top, m.bg(""))
|
||||||
top = append(top, m.bg(line))
|
top = append(top, m.bg(m.renderColumnHeader()))
|
||||||
}
|
top = append(top, m.bg(""))
|
||||||
|
top = append(top, m.renderFileRows()...)
|
||||||
|
|
||||||
bottom = append(bottom, m.bg(m.renderDivider()))
|
bottom = append(bottom, m.bg(m.renderDivider()))
|
||||||
bottom = append(bottom, m.bg(m.renderResultsFooter()))
|
bottom = append(bottom, m.bg(m.renderResultsBar()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
@@ -109,13 +106,24 @@ func (m Model) View() string {
|
|||||||
|
|
||||||
func (m Model) renderTitleBar() string {
|
func (m Model) renderTitleBar() string {
|
||||||
title := theme.Bg(theme.Logo).Render("transmute")
|
title := theme.Bg(theme.Logo).Render("transmute")
|
||||||
|
|
||||||
selected := 0
|
selected := 0
|
||||||
for _, f := range m.files {
|
for _, f := range m.files {
|
||||||
if f.selected {
|
if f.selected {
|
||||||
selected++
|
selected++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info := theme.Bg(theme.Breadcrumb).Render(fmt.Sprintf(" %d files · %d selected", len(m.files), 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 if m.state == stateResults {
|
||||||
|
infoText = fmt.Sprintf(" %d files · %d converted", len(m.files), m.converted)
|
||||||
|
} else {
|
||||||
|
infoText = fmt.Sprintf(" %d files · %d selected", len(m.files), selected)
|
||||||
|
}
|
||||||
|
info := theme.Bg(theme.Breadcrumb).Render(infoText)
|
||||||
|
|
||||||
left := theme.BgStr(" ") + title + info
|
left := theme.BgStr(" ") + title + info
|
||||||
right := theme.Bg(theme.Help).Render("? help") + theme.BgStr(" ")
|
right := theme.Bg(theme.Help).Render("? help") + theme.BgStr(" ")
|
||||||
@@ -231,8 +239,10 @@ func (m Model) renderFileRow(idx int) string {
|
|||||||
// Size
|
// Size
|
||||||
sizeCell := bgS(theme.FileSize).Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
|
sizeCell := bgS(theme.FileSize).Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
|
||||||
|
|
||||||
// Format selector
|
// Format selector — hide arrows during conversion/results (keys are blocked)
|
||||||
fmtStr := renderFormatSelector(f, isCursor, bgStr, bgS)
|
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)
|
fmtCell := bgS(lipgloss.NewStyle()).Copy().Width(colFormat).Align(lipgloss.Center).Render(fmtStr)
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
@@ -281,6 +291,25 @@ func renderFormatSelector(f fileEntry, active bool, bgStr func(string) string, b
|
|||||||
// ─── Bottom bar ──────────────────────────────────────────────
|
// ─── Bottom bar ──────────────────────────────────────────────
|
||||||
|
|
||||||
func (m Model) renderBottomBar() string {
|
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
|
selected := 0
|
||||||
for _, f := range m.files {
|
for _, f := range m.files {
|
||||||
if f.selected {
|
if f.selected {
|
||||||
@@ -315,47 +344,8 @@ func (m Model) renderBottomBar() string {
|
|||||||
return left + theme.BgStr(strings.Repeat(" ", gap)) + right
|
return left + theme.BgStr(strings.Repeat(" ", gap)) + right
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Converting view ─────────────────────────────────────────
|
// renderResultsBar shows a summary after all conversions are done.
|
||||||
|
func (m Model) renderResultsBar() string {
|
||||||
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
|
successCount := 0
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
for _, f := range m.files {
|
for _, f := range m.files {
|
||||||
@@ -370,46 +360,20 @@ func (m Model) renderResults() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(m.startTime).Round(time.Millisecond)
|
elapsed := time.Since(m.startTime).Round(time.Millisecond)
|
||||||
summary := theme.Bg(theme.StatusDone).Render(fmt.Sprintf(
|
left := theme.BgStr(" ") +
|
||||||
" Conversion complete! %d succeeded", successCount))
|
theme.Bg(theme.StatusDone).Render(fmt.Sprintf(" %d converted ", successCount))
|
||||||
if errorCount > 0 {
|
if errorCount > 0 {
|
||||||
summary += theme.Bg(theme.StatusError).Render(fmt.Sprintf(", %d failed", errorCount))
|
left += theme.BgStr(" ") + theme.Bg(theme.StatusError).Render(fmt.Sprintf(" %d failed ", errorCount))
|
||||||
}
|
}
|
||||||
summary += theme.Bg(theme.Help).Render(fmt.Sprintf(" (%s)", elapsed))
|
left += theme.Bg(theme.Help).Render(fmt.Sprintf(" %s", elapsed))
|
||||||
|
|
||||||
rows = append(rows, "", summary, "")
|
right := theme.Bg(theme.Help).Render("enter quit esc convert more") + theme.BgStr(" ")
|
||||||
|
|
||||||
for _, f := range m.files {
|
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||||
if !f.selected {
|
if gap < 1 {
|
||||||
continue
|
gap = 1
|
||||||
}
|
|
||||||
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 left + theme.BgStr(strings.Repeat(" ", gap)) + right
|
||||||
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 ────────────────────────────────────────────
|
// ─── Help overlay ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// CurrentVersion is the embedded build version. Updated at release time.
|
// CurrentVersion is the embedded build version. Updated at release time.
|
||||||
CurrentVersion = "0.1.3"
|
CurrentVersion = "0.1.4"
|
||||||
|
|
||||||
repoOwner = "noauf"
|
repoOwner = "noauf"
|
||||||
repoName = "Transmute"
|
repoName = "Transmute"
|
||||||
|
|||||||
Reference in New Issue
Block a user