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
This commit is contained in:
noah
2026-03-11 10:32:06 +01:00
parent 5f1743ddce
commit 82a4123f84
4 changed files with 66 additions and 122 deletions
+22 -58
View File
@@ -22,7 +22,6 @@ type state int
const ( const (
stateFileList state = iota // Browsing/selecting files (also used during conversion) stateFileList state = iota // Browsing/selecting files (also used during conversion)
stateResults // All conversions finished — still shows file list
) )
// ─── File entry ────────────────────────────────────────────── // ─── File entry ──────────────────────────────────────────────
@@ -158,7 +157,7 @@ func makeFileEntry(path string, info os.FileInfo) *fileEntry {
ext: ext, ext: ext,
size: info.Size(), size: info.Size(),
category: detect.DetectCategory(ext), category: detect.DetectCategory(ext),
selected: true, // Select all by default selected: false, // Don't select by default
targetFormat: defaultTarget, targetFormat: defaultTarget,
formats: formats, formats: formats,
formatIdx: defaultIdx, formatIdx: defaultIdx,
@@ -193,13 +192,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.converted++ m.converted++
} }
// Check if all done // Start next conversion if there are more files
if m.converted >= m.totalToConv {
m.state = stateResults
return m, nil
}
// Start next conversion
m, cmd := m.convertNext() m, cmd := m.convertNext()
return m, cmd return m, cmd
@@ -211,13 +204,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.state { return m.handleFileListKey(msg)
case stateFileList:
return m.handleFileListKey(msg)
case stateResults:
return m.handleResultsKey(msg)
}
return m, nil
} }
func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -294,63 +281,40 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, m.keys.Preview): case key.Matches(msg, m.keys.Preview):
if len(m.files) > 0 {
openFile(m.files[m.cursor].path)
}
}
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 == "deleted" {
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--
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.Preview):
// Preview: open output file if done, otherwise open input file
if len(m.files) > 0 { if len(m.files) > 0 {
f := m.files[m.cursor] f := m.files[m.cursor]
// Open output if done, otherwise input
path := f.path
if f.status == "done" && f.outputPath != "" { if f.status == "done" && f.outputPath != "" {
openFile(f.outputPath) path = f.outputPath
} else {
openFile(f.path)
} }
openFile(path)
} }
case key.Matches(msg, m.keys.DeleteOutput): case key.Matches(msg, m.keys.DeleteOutput):
// Delete the converted output file from disk // Delete the converted output file from disk
if len(m.files) > 0 { if len(m.files) > 0 && !isConverting {
f := &m.files[m.cursor] f := &m.files[m.cursor]
if f.status == "done" && f.outputPath != "" { if f.status == "done" && f.outputPath != "" {
if err := os.Remove(f.outputPath); err == nil { if err := os.Remove(f.outputPath); err == nil {
f.status = "deleted" f.status = "idle"
f.outputPath = "" f.outputPath = ""
} }
} }
} }
case key.Matches(msg, m.keys.Back):
// Reset done/error files to idle for reconversion
if len(m.files) > 0 && !isConverting {
f := &m.files[m.cursor]
if f.status == "done" || f.status == "error" || f.status == "deleted" {
f.status = "idle"
f.error = ""
f.outputPath = ""
}
}
} }
return m, nil return m, nil
} }
+22 -36
View File
@@ -30,9 +30,9 @@ func TestInlineConversionStateMachine(t *testing.T) {
if m.files[0].status != "idle" { if m.files[0].status != "idle" {
t.Fatalf("expected idle status, got %s", m.files[0].status) 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") // Select the file (default is now false)
} m.files[0].selected = true
t.Logf("File: %s -> %s (selected: %v)", m.files[0].name, m.files[0].targetFormat, m.files[0].selected) t.Logf("File: %s -> %s (selected: %v)", m.files[0].name, m.files[0].targetFormat, m.files[0].selected)
@@ -62,8 +62,9 @@ func TestInlineConversionStateMachine(t *testing.T) {
newModel, _ = m.Update(msg) newModel, _ = m.Update(msg)
m = newModel.(Model) m = newModel.(Model)
if m.state != stateResults { // Should stay on stateFileList after conversion (not stateResults)
t.Fatalf("expected stateResults after all conversions done, got %d", m.state) if m.state != stateFileList {
t.Fatalf("expected stateFileList after conversion, got %d", m.state)
} }
if m.files[0].status != "done" { if m.files[0].status != "done" {
t.Fatalf("expected 'done' status, got '%s' (error: %s)", m.files[0].status, m.files[0].error) t.Fatalf("expected 'done' status, got '%s' (error: %s)", m.files[0].status, m.files[0].error)
@@ -80,18 +81,7 @@ func TestInlineConversionStateMachine(t *testing.T) {
t.Logf("Conversion complete: %s (%d bytes)", m.files[0].outputPath, info.Size()) t.Logf("Conversion complete: %s (%d bytes)", m.files[0].outputPath, info.Size())
// Test 'esc' to go back to file list t.Log("State machine verified: idle -> converting -> done (stays on 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 TestDeleteOutput(t *testing.T) { func TestDeleteOutput(t *testing.T) {
@@ -105,6 +95,9 @@ func TestDeleteOutput(t *testing.T) {
m.width = 80 m.width = 80
m.height = 24 m.height = 24
// Select the file (default is now false)
m.files[0].selected = true
// Convert the file // Convert the file
newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
m = newModel.(Model) m = newModel.(Model)
@@ -112,33 +105,23 @@ func TestDeleteOutput(t *testing.T) {
newModel, _ = m.Update(msg) newModel, _ = m.Update(msg)
m = newModel.(Model) m = newModel.(Model)
if m.state != stateResults { // Should stay on stateFileList
t.Fatalf("expected stateResults, got %d", m.state) if m.state != stateFileList {
t.Fatalf("expected stateFileList, got %d", m.state)
} }
if m.files[0].status != "done" { if m.files[0].status != "done" {
t.Fatalf("expected done, got %s", m.files[0].status) t.Fatalf("expected done, got %s", m.files[0].status)
} }
outputPath := m.files[0].outputPath // Test pressing 'x' to delete the output and reset to idle
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("output file should exist: %v", err)
}
// Press 'x' to delete the output
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
m = newModel.(Model) m = newModel.(Model)
if m.files[0].status != "deleted" { if m.files[0].status != "idle" {
t.Fatalf("expected 'deleted' status after pressing x, got '%s'", m.files[0].status) t.Fatalf("expected 'idle' status after pressing x, got '%s'", m.files[0].status)
}
if m.files[0].outputPath != "" {
t.Fatal("outputPath should be cleared after delete")
}
if _, err := os.Stat(outputPath); err == nil {
t.Fatal("output file should have been deleted from disk")
} }
t.Log("Delete output works: file removed from disk, status set to 'deleted'") t.Log("Delete output works: file removed from disk, status reset to 'idle'")
} }
func TestViewRendersDuringConversion(t *testing.T) { func TestViewRendersDuringConversion(t *testing.T) {
@@ -159,10 +142,13 @@ func TestViewRendersDuringConversion(t *testing.T) {
if !containsStr(view, "idle") { if !containsStr(view, "idle") {
t.Error("initial view should contain 'idle' status") t.Error("initial view should contain 'idle' status")
} }
if !containsStr(view, "Convert 1 files") { if !containsStr(view, "Select files to convert") {
t.Error("initial view should contain convert button") t.Error("initial view should contain 'Select files to convert'")
} }
// Select a file and start conversion
m.files[0].selected = true
// Start conversion // Start conversion
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
m = newModel.(Model) m = newModel.(Model)
+21 -27
View File
@@ -61,15 +61,6 @@ 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 stateResults:
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.renderResultsBar()))
} }
if m.showHelp { if m.showHelp {
@@ -118,8 +109,6 @@ func (m Model) renderTitleBar() string {
isConverting := m.totalToConv > 0 && m.converted < m.totalToConv isConverting := m.totalToConv > 0 && m.converted < m.totalToConv
if isConverting { if isConverting {
infoText = fmt.Sprintf(" %d files · converting %d/%d", len(m.files), m.converted, m.totalToConv) 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 { } else {
infoText = fmt.Sprintf(" %d files · %d selected", len(m.files), selected) infoText = fmt.Sprintf(" %d files · %d selected", len(m.files), selected)
} }
@@ -385,29 +374,34 @@ func (m Model) renderHelp() string {
key string key string
desc string desc string
}{ }{
{"up/down, j/k", "Navigate files"}, {"↑/↓ or j/k", "Navigate files"},
{"left/right, h/l", "Change target format"}, {"←/→ or h/l", "Change target format"},
{"space", "Toggle file selection"}, {"space", "Toggle selection"},
{"a", "Select / deselect all"}, {"a", "Select / deselect all"},
{"p", "Preview file"}, {"p", "Preview / open file"},
{"d", "Remove file from list"}, {"d", "Remove from list"},
{"x", "Delete converted output"}, {"x", "Delete output"},
{"c or enter", "Start conversion"}, {"c or enter", "Convert selected"},
{"esc", "Go back"}, {"? or q", "Close / quit"},
{"q or ctrl+c", "Quit"},
} }
// Title bar
var lines []string var lines []string
lines = append(lines, "") title := theme.Bg(theme.Logo).Render(" Keyboard Shortcuts ")
lines = append(lines, theme.Bg(theme.BreadcrumbActive).Render(" Keyboard Shortcuts")) divider := theme.Bg(theme.Divider).Render(strings.Repeat("─", m.width-4))
lines = append(lines, "")
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 { for _, k := range keys {
lines = append(lines, fmt.Sprintf(" %s %s", keyStr := theme.Bg(theme.Selected).Copy().Width(16).Render(k.key)
theme.Bg(theme.Selected).Copy().Width(18).Render(k.key), descStr := theme.Bg(theme.Help).Render(" " + k.desc + " ")
theme.Bg(theme.Help).Render(k.desc))) line := m.bg(keyStr + descStr)
lines = append(lines, line)
} }
lines = append(lines, "")
lines = append(lines, m.bg(divider))
return lipgloss.JoinVertical(lipgloss.Left, lines...) return lipgloss.JoinVertical(lipgloss.Left, lines...)
} }
+1 -1
View File
@@ -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.4" CurrentVersion = "0.1.5"
repoOwner = "noauf" repoOwner = "noauf"
repoName = "Transmute" repoName = "Transmute"