diff --git a/cli/internal/converter/converter_test.go b/cli/internal/converter/converter_test.go new file mode 100644 index 0000000..8f2ec3a --- /dev/null +++ b/cli/internal/converter/converter_test.go @@ -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()) +} diff --git a/cli/internal/converter/image.go b/cli/internal/converter/image.go index f4c0a58..c0b28ce 100644 --- a/cli/internal/converter/image.go +++ b/cli/internal/converter/image.go @@ -7,6 +7,7 @@ import ( "image/jpeg" "image/png" "os" + "os/exec" "strings" "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 { + // 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} switch format { diff --git a/cli/internal/tui/model.go b/cli/internal/tui/model.go index d16b1b7..5be89b8 100644 --- a/cli/internal/tui/model.go +++ b/cli/internal/tui/model.go @@ -19,9 +19,8 @@ import ( type state int const ( - stateFileList state = iota // Browsing/selecting files - stateConverting // Conversion in progress - stateResults // Showing results + stateFileList state = iota // Browsing/selecting files (also used during conversion) + stateResults // All conversions finished — still shows file list ) // ─── File entry ────────────────────────────────────────────── @@ -199,7 +198,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Start next conversion - return m, m.convertNext() + m, cmd := m.convertNext() + return m, cmd case tea.KeyMsg: return m.handleKey(msg) @@ -212,12 +212,6 @@ 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) } @@ -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) { + isConverting := m.totalToConv > 0 && m.converted < m.totalToConv + switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit @@ -241,13 +237,17 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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): - if len(m.files) > 0 { + if !isConverting && 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 { + if !isConverting && len(m.files) > 0 { f := &m.files[m.cursor] if f.formatIdx > 0 { 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): - if len(m.files) > 0 { + if !isConverting && len(m.files) > 0 { f := &m.files[m.cursor] if f.formatIdx < len(f.formats)-1 { f.formatIdx++ @@ -265,19 +265,21 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keys.SelectAll): - allSelected := true - for _, f := range m.files { - if !f.selected { - allSelected = false - break + if !isConverting { + allSelected := true + for _, f := range m.files { + if !f.selected { + 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): - if len(m.files) > 0 { + if !isConverting && 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-- @@ -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): - return m.startConversion() - - case key.Matches(msg, m.keys.Help): - m.showHelp = !m.showHelp + if !isConverting { + return m.startConversion() + } } return m, nil @@ -337,16 +338,17 @@ func (m Model) startConversion() (Model, tea.Cmd) { return m, nil } - m.state = stateConverting + // Stay on stateFileList — status column updates inline m.totalToConv = count m.converted = 0 m.converting = 0 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 for i := range m.files { 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 outDir := m.outputDir - return func() tea.Msg { + return m, func() tea.Msg { result := converter.Convert(path, target, outDir) return conversionDoneMsg{index: idx, result: result} } } } - return nil + return m, nil } func (m *Model) ensureVisible() { diff --git a/cli/internal/tui/model_test.go b/cli/internal/tui/model_test.go new file mode 100644 index 0000000..f8d5ea3 --- /dev/null +++ b/cli/internal/tui/model_test.go @@ -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 +} diff --git a/cli/internal/tui/views.go b/cli/internal/tui/views.go index ba43347..4956496 100644 --- a/cli/internal/tui/views.go +++ b/cli/internal/tui/views.go @@ -62,17 +62,14 @@ func (m Model) View() string { 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)) - } + top = append(top, m.bg("")) + 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.renderResultsFooter())) + bottom = append(bottom, m.bg(m.renderResultsBar())) } if m.showHelp { @@ -109,13 +106,24 @@ func (m Model) View() string { 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)) + + 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 right := theme.Bg(theme.Help).Render("? help") + theme.BgStr(" ") @@ -231,8 +239,10 @@ func (m Model) renderFileRow(idx int) string { // Size sizeCell := bgS(theme.FileSize).Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size)) - // Format selector - fmtStr := renderFormatSelector(f, isCursor, bgStr, bgS) + // 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 @@ -281,6 +291,25 @@ func renderFormatSelector(f fileEntry, active bool, bgStr func(string) string, b // ─── 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 { @@ -315,47 +344,8 @@ func (m Model) renderBottomBar() string { 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 - +// renderResultsBar shows a summary after all conversions are done. +func (m Model) renderResultsBar() string { successCount := 0 errorCount := 0 for _, f := range m.files { @@ -370,46 +360,20 @@ func (m Model) renderResults() string { } elapsed := time.Since(m.startTime).Round(time.Millisecond) - summary := theme.Bg(theme.StatusDone).Render(fmt.Sprintf( - " Conversion complete! %d succeeded", successCount)) + left := theme.BgStr(" ") + + theme.Bg(theme.StatusDone).Render(fmt.Sprintf(" %d converted ", successCount)) 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 { - 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)) - } + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 } - - 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") + return left + theme.BgStr(strings.Repeat(" ", gap)) + right } // ─── Help overlay ──────────────────────────────────────────── diff --git a/cli/internal/update/update.go b/cli/internal/update/update.go index 12f1d21..b9a41cb 100644 --- a/cli/internal/update/update.go +++ b/cli/internal/update/update.go @@ -14,7 +14,7 @@ import ( const ( // CurrentVersion is the embedded build version. Updated at release time. - CurrentVersion = "0.1.3" + CurrentVersion = "0.1.4" repoOwner = "noauf" repoName = "Transmute"