fix: per-segment bg coloring for full-screen cream + warm cursor highlight

Previous approach using Place() for full-screen bg and WarmRowStyle wrapper
failed because inner ANSI resets (\x1b[m) kill outer Background() styles.

New approach: every styled segment carries its own Background() via theme.Bg()
and theme.WBg() helpers. Per-line Place() fills trailing whitespace. This
ensures cream bg (#fdf6ef) on every pixel of every line, and warm bg (#f8f0e6)
consistently across the cursor row.

Also fixes bottom bar overflow on narrow terminals with adaptive keybindings.
This commit is contained in:
noah
2026-03-09 23:34:52 +01:00
parent 4d5e73cfd1
commit ed25ffa533
3 changed files with 184 additions and 312 deletions
+28 -76
View File
@@ -2,7 +2,6 @@ package theme
import ( import (
"image/color" "image/color"
"strings"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
@@ -58,66 +57,47 @@ func CategoryColor(cat string) color.Color {
// ─── Reusable styles ───────────────────────────────────────── // ─── Reusable styles ─────────────────────────────────────────
// //
// IMPORTANT: Every style MUST have Background(ScreenBg) so the cream // Styles do NOT set Background — the full-screen Place() call in
// background propagates through all ANSI sequences. Wrapping // View() paints ALL whitespace with ScreenBg via WithWhitespaceStyle.
// already-styled text with a background style does NOT work because
// inner escape sequences reset the background.
var ( var (
// Title bar
TitleBar = lipgloss.NewStyle().
Bold(true).
Foreground(Dark).
Background(ScreenBg).
Padding(0, 2)
// Header / breadcrumb // Header / breadcrumb
Breadcrumb = lipgloss.NewStyle(). Breadcrumb = lipgloss.NewStyle().
Foreground(Mid). Foreground(Mid)
Background(ScreenBg).
Bold(false)
BreadcrumbActive = lipgloss.NewStyle(). BreadcrumbActive = lipgloss.NewStyle().
Foreground(Dark). Foreground(Dark).
Background(ScreenBg).
Bold(true) Bold(true)
// File row // File row
FileName = lipgloss.NewStyle(). FileName = lipgloss.NewStyle().
Foreground(Dark). Foreground(Dark).
Background(ScreenBg).
Bold(true) Bold(true)
FileSize = lipgloss.NewStyle(). FileSize = lipgloss.NewStyle().
Foreground(Light). Foreground(Light)
Background(ScreenBg)
ExtBadge = func(c color.Color) lipgloss.Style { ExtBadge = func(c color.Color) lipgloss.Style {
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Foreground(c). Foreground(c).
Background(ScreenBg).
Bold(true) Bold(true)
} }
// Status indicators // Status indicators
StatusIdle = lipgloss.NewStyle(). StatusIdle = lipgloss.NewStyle().
Foreground(Light). Foreground(Light).
Background(ScreenBg).
Italic(true) Italic(true)
StatusConverting = lipgloss.NewStyle(). StatusConverting = lipgloss.NewStyle().
Foreground(Pink). Foreground(Pink).
Background(ScreenBg).
Bold(true) Bold(true)
StatusDone = lipgloss.NewStyle(). StatusDone = lipgloss.NewStyle().
Foreground(Mint). Foreground(Mint).
Background(ScreenBg).
Bold(true) Bold(true)
StatusError = lipgloss.NewStyle(). StatusError = lipgloss.NewStyle().
Foreground(Red). Foreground(Red).
Background(ScreenBg).
Bold(true) Bold(true)
// Buttons / actions (these keep their own bg colors) // Buttons / actions (these keep their own bg colors)
@@ -127,87 +107,59 @@ var (
Bold(true). Bold(true).
Padding(0, 2) Padding(0, 2)
ButtonSecondary = lipgloss.NewStyle().
Foreground(lipgloss.Color("#ffffff")).
Background(Mint).
Bold(true).
Padding(0, 2)
// Progress bar // Progress bar
ProgressFilled = lipgloss.NewStyle(). ProgressFilled = lipgloss.NewStyle().
Foreground(Pink). Foreground(Pink)
Background(ScreenBg)
ProgressEmpty = lipgloss.NewStyle(). ProgressEmpty = lipgloss.NewStyle().
Foreground(BorderCl). Foreground(BorderCl)
Background(ScreenBg)
// Help / footer // Help / footer
Help = lipgloss.NewStyle(). Help = lipgloss.NewStyle().
Foreground(Light). Foreground(Light).
Background(ScreenBg).
Italic(true) Italic(true)
// Cursor / selection // Cursor / selection
Selected = lipgloss.NewStyle(). Selected = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(Pink). Foreground(Pink)
Background(ScreenBg)
Unselected = lipgloss.NewStyle(). Unselected = lipgloss.NewStyle().
Foreground(Dark). Foreground(Dark)
Background(ScreenBg)
// Divider // Divider
Divider = lipgloss.NewStyle(). Divider = lipgloss.NewStyle().
Foreground(BorderCl). Foreground(BorderCl)
Background(ScreenBg)
// Logo / branding // Logo / branding
Logo = lipgloss.NewStyle(). Logo = lipgloss.NewStyle().
Foreground(Pink). Foreground(Pink).
Background(ScreenBg).
Bold(true) Bold(true)
) )
// BgStyle returns a plain style with just the cream background, useful // ScreenStyle is the whitespace style used by Place().
// for spacing characters that need to carry the background color. // It paints trailing/fill space with the cream background.
var BgStyle = lipgloss.NewStyle().Background(ScreenBg) var ScreenStyle = lipgloss.NewStyle().Background(ScreenBg)
// WarmBgStyle returns a plain style with the warm highlight background. // WarmWhitespace is the whitespace style for cursor row Place().
var WarmBgStyle = lipgloss.NewStyle().Background(Warm) var WarmWhitespace = lipgloss.NewStyle().Background(Warm)
// PadLine pads a single rendered line to the full terminal width with // Bg adds Background(ScreenBg) to a style copy — for normal rows.
// cream background spaces on the right edge. func Bg(s lipgloss.Style) lipgloss.Style {
func PadLine(line string, width int) string { return s.Copy().Background(ScreenBg)
w := lipgloss.Width(line)
if w >= width {
return line
}
pad := BgStyle.Render(strings.Repeat(" ", width-w))
return line + pad
} }
// PadLineWithBg pads a line to full width with a specific background color. // WBg adds Background(Warm) to a style copy — for cursor/active row.
func PadLineWithBg(line string, width int, bg color.Color) string { func WBg(s lipgloss.Style) lipgloss.Style {
w := lipgloss.Width(line) return s.Copy().Background(Warm)
if w >= width {
return line
}
pad := lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", width-w))
return line + pad
} }
// FillBlankLines returns n blank lines fully painted with the screen // BgStr renders plain (unstyled) text with ScreenBg background.
// background color at the given width. func BgStr(s string) string {
func FillBlankLines(n, width int) string { return lipgloss.NewStyle().Background(ScreenBg).Render(s)
if n <= 0 { }
return ""
} // WBgStr renders plain (unstyled) text with Warm background.
blankLine := BgStyle.Render(strings.Repeat(" ", width)) func WBgStr(s string) string {
lines := make([]string, n) return lipgloss.NewStyle().Background(Warm).Render(s)
for i := range lines {
lines[i] = blankLine
}
return strings.Join(lines, "\n")
} }
+155 -235
View File
@@ -11,178 +11,151 @@ import (
"github.com/noauf/transmute-cli/internal/theme" "github.com/noauf/transmute-cli/internal/theme"
) )
// Fixed column widths (matching the web simulation layout). // Fixed column widths for the right-side columns.
const ( const (
colCursor = 3 // "> " or " "
colCheck = 3 // "● " or "○ "
colSize = 10 // right-aligned file size colSize = 10 // right-aligned file size
colFormat = 14 // "< webp >" centered colFormat = 14 // "< webp >" centered
colStatus = 14 // "idle" / "converting..." / "done" colStatus = 14 // "idle" / "converting..." / "done"
colGap = 2 // gap between columns
) )
// bg renders spacing text with the cream background. // nameWidth returns the flexible Name column width (everything left of Size).
func bg(s string) string {
return theme.BgStyle.Render(s)
}
// wbg renders spacing text with the warm highlight background.
func wbg(s string) string {
return theme.WarmBgStyle.Render(s)
}
// nameWidth calculates the flexible Name column width.
func nameWidth(termW int) int { func nameWidth(termW int) int {
fixed := colCursor + colCheck + colSize + colFormat + colStatus + colGap*3 // prefix(6) + name(flex) + gap(2) + size(10) + gap(2) + fmt(14) + gap(2) + status(14)
w := termW - fixed w := termW - 6 - 2 - colSize - 2 - colFormat - 2 - colStatus
if w < 20 { if w < 20 {
w = 20 w = 20
} }
return w return w
} }
// pad pads a line to full width with cream background. // bg wraps a line so it fills the full terminal width with cream bg.
func (m Model) pad(line string) string { func (m Model) bg(line string) string {
return theme.PadLine(line, m.width) return lipgloss.Place(m.width, 1, lipgloss.Left, lipgloss.Top, line,
lipgloss.WithWhitespaceStyle(theme.ScreenStyle))
} }
// padWarm pads a line to full width with the warm (highlighted) background. // wbg wraps a line so it fills the full terminal width with warm bg (cursor row).
func (m Model) padWarm(line string) string { func (m Model) wbg(line string) string {
return theme.PadLineWithBg(line, m.width, theme.Warm) return lipgloss.Place(m.width, 1, lipgloss.Left, lipgloss.Top, line,
lipgloss.WithWhitespaceStyle(theme.WarmWhitespace))
} }
// blank returns a full-width blank line with cream background. // View renders the full-screen TUI.
func (m Model) blank() string {
return theme.BgStyle.Render(strings.Repeat(" ", m.width))
}
// View renders the entire TUI, filling the full terminal.
func (m Model) View() string { func (m Model) View() string {
if m.width == 0 || m.height == 0 { if m.width == 0 || m.height == 0 {
return "Loading..." return "Loading..."
} }
// We build an array of pre-padded lines (each already full-width with bg). var top []string // content at the top
var lines []string var bottom []string // content pinned to the bottom
// Title bar (1 line) top = append(top, m.bg(m.renderTitleBar()))
lines = append(lines, m.pad(m.renderTitleBar())) top = append(top, m.bg(m.renderDivider()))
// Divider (1 line)
lines = append(lines, m.pad(m.renderDivider()))
// State-specific content
var bottomLines []string // lines that go at the very bottom
switch m.state { switch m.state {
case stateFileList: case stateFileList:
// Column header top = append(top, m.bg(""))
lines = append(lines, m.pad(m.renderColumnHeader())) top = append(top, m.bg(m.renderColumnHeader()))
top = append(top, m.bg(""))
top = append(top, m.renderFileRows()...) // rows already have bg/wbg applied
// File rows bottom = append(bottom, m.bg(m.renderDivider()))
fileLines := m.renderFileRows() bottom = append(bottom, m.bg(m.renderBottomBar()))
lines = append(lines, fileLines...)
// Bottom section: divider + bottom bar (pinned to bottom)
bottomLines = append(bottomLines, m.pad(m.renderDivider()))
bottomLines = append(bottomLines, m.pad(m.renderBottomBar()))
case stateConverting: case stateConverting:
for _, l := range strings.Split(m.renderConverting(), "\n") { for _, line := range strings.Split(m.renderConverting(), "\n") {
lines = append(lines, m.pad(l)) top = append(top, m.bg(line))
} }
case stateResults: case stateResults:
for _, l := range strings.Split(m.renderResults(), "\n") { for _, line := range strings.Split(m.renderResults(), "\n") {
lines = append(lines, m.pad(l)) top = append(top, m.bg(line))
} }
bottomLines = append(bottomLines, m.pad(m.renderDivider())) bottom = append(bottom, m.bg(m.renderDivider()))
bottomLines = append(bottomLines, m.pad(m.renderResultsFooter())) bottom = append(bottom, m.bg(m.renderResultsFooter()))
} }
// Help overlay (if visible, goes right after content)
if m.showHelp { if m.showHelp {
for _, l := range strings.Split(m.renderHelp(), "\n") { for _, line := range strings.Split(m.renderHelp(), "\n") {
lines = append(lines, m.pad(l)) top = append(top, m.bg(line))
} }
} }
// Calculate how many blank lines we need between content and bottom bar // Assemble: top lines + blank fill + bottom lines
totalUsed := len(lines) + len(bottomLines) totalUsed := len(top) + len(bottom)
remaining := m.height - totalUsed fill := m.height - totalUsed
for i := 0; i < remaining; i++ { if fill < 0 {
lines = append(lines, m.blank()) fill = 0
} }
// Append bottom bar lines blankLine := m.bg("")
lines = append(lines, bottomLines...)
// Truncate if somehow we exceed terminal height var all []string
if len(lines) > m.height { all = append(all, top...)
lines = lines[:m.height] 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(lines, "\n") return strings.Join(all, "\n")
} }
// ─── Title bar ─────────────────────────────────────────────── // ─── Title bar ───────────────────────────────────────────────
func (m Model) renderTitleBar() string { func (m Model) renderTitleBar() string {
title := theme.Logo.Render("transmute") title := theme.Bg(theme.Logo).Render("transmute")
fileCount := fmt.Sprintf("%d files", len(m.files))
selected := 0 selected := 0
for _, f := range m.files { for _, f := range m.files {
if f.selected { if f.selected {
selected++ selected++
} }
} }
info := theme.Breadcrumb.Render(fmt.Sprintf(" %s \u00B7 %d selected", fileCount, selected)) info := theme.Bg(theme.Breadcrumb).Render(fmt.Sprintf(" %d files · %d selected", len(m.files), selected))
left := bg(" ") + title + info left := theme.BgStr(" ") + title + info
rightContent := theme.Help.Render("? help") + bg(" ") right := theme.Bg(theme.Help).Render("? help") + theme.BgStr(" ")
gap := m.width - lipgloss.Width(left) - lipgloss.Width(rightContent) gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 { if gap < 1 {
gap = 1 gap = 1
} }
return left + theme.BgStr(strings.Repeat(" ", gap)) + right
return left + bg(strings.Repeat(" ", gap)) + rightContent
} }
// ─── Divider ───────────────────────────────────────────────── // ─── Divider ─────────────────────────────────────────────────
func (m Model) renderDivider() string { func (m Model) renderDivider() string {
w := m.width - 4 // 2 char padding each side w := m.width - 4
if w < 10 { if w < 10 {
w = 10 w = 10
} }
return bg(" ") + theme.Divider.Render(strings.Repeat("\u2500", w)) + bg(" ") return theme.BgStr(" ") + theme.Bg(theme.Divider).Render(strings.Repeat("", w)) + theme.BgStr(" ")
} }
// ─── Column header ─────────────────────────────────────────── // ─── Column header ───────────────────────────────────────────
func (m Model) renderColumnHeader() string { func (m Model) renderColumnHeader() string {
nw := nameWidth(m.width) nw := nameWidth(m.width)
prefix := theme.BgStr(strings.Repeat(" ", 6)) // cursor(3) + check(3)
nameHdr := theme.Breadcrumb.Copy().Width(colCursor + colCheck + nw).Render( nameHdr := theme.Bg(theme.Breadcrumb).Copy().Width(nw).Render("Name")
strings.Repeat(" ", colCursor+colCheck) + "Name") sizeHdr := theme.Bg(theme.Breadcrumb).Copy().Width(colSize).Align(lipgloss.Right).Render("Size")
sizeHdr := theme.Breadcrumb.Copy().Width(colSize).Align(lipgloss.Right).Render("Size") fmtHdr := theme.Bg(theme.Breadcrumb).Copy().Width(colFormat).Align(lipgloss.Center).Render("Convert to")
fmtHdr := theme.Breadcrumb.Copy().Width(colFormat).Align(lipgloss.Center).Render("Convert to") statHdr := theme.Bg(theme.Breadcrumb).Copy().Width(colStatus).Align(lipgloss.Center).Render("Status")
statHdr := theme.Breadcrumb.Copy().Width(colStatus).Align(lipgloss.Center).Render("Status")
return nameHdr + bg(" ") + sizeHdr + bg(" ") + fmtHdr + bg(" ") + statHdr return prefix + nameHdr + theme.BgStr(" ") + sizeHdr + theme.BgStr(" ") + fmtHdr + theme.BgStr(" ") + statHdr
} }
// ─── File rows ─────────────────────────────────────────────── // ─── File rows ───────────────────────────────────────────────
// renderFileRows returns already-padded lines for the file list area.
func (m Model) renderFileRows() []string { func (m Model) renderFileRows() []string {
if len(m.files) == 0 { if len(m.files) == 0 {
empty := lipgloss.NewStyle(). msg := theme.Bg(theme.Help).Render(" No supported files found. Pass file paths or glob patterns as arguments.")
Foreground(theme.Light). return []string{m.bg(""), m.bg(msg), m.bg("")}
Background(theme.ScreenBg).
Italic(true).
Render(" No supported files found. Pass file paths or glob patterns as arguments.")
return []string{m.blank(), m.pad(empty), m.blank()}
} }
maxVisible := m.maxVisibleFiles() maxVisible := m.maxVisibleFiles()
@@ -193,20 +166,12 @@ func (m Model) renderFileRows() []string {
var rows []string var rows []string
for i := m.scroll; i < end; i++ { for i := m.scroll; i < end; i++ {
isCursor := i == m.cursor rows = append(rows, m.renderFileRow(i))
row := m.renderFileRow(i)
if isCursor {
rows = append(rows, m.padWarm(row))
} else {
rows = append(rows, m.pad(row))
}
} }
// Scrollbar indicator (if needed)
if len(m.files) > maxVisible { if len(m.files) > maxVisible {
scrollInfo := theme.Help.Render(fmt.Sprintf( rows = append(rows, m.bg(theme.Bg(theme.Help).Render(fmt.Sprintf(
" showing %d\u2013%d of %d", m.scroll+1, end, len(m.files))) " showing %d%d of %d", m.scroll+1, end, len(m.files)))))
rows = append(rows, m.pad(scrollInfo))
} }
return rows return rows
@@ -217,44 +182,34 @@ func (m Model) renderFileRow(idx int) string {
isCursor := idx == m.cursor isCursor := idx == m.cursor
nw := nameWidth(m.width) nw := nameWidth(m.width)
// Choose background helper based on whether this is the cursor row // Pick background helpers based on cursor state
sp := bg bgStr := theme.BgStr
bgS := theme.Bg
lineBg := m.bg
if isCursor { if isCursor {
sp = wbg bgStr = theme.WBgStr
bgS = theme.WBg
lineBg = m.wbg
} }
// ── Cursor indicator ── // Cursor indicator
cursor := sp(" ") cursor := bgStr(" ")
if isCursor { if isCursor {
cursor = sp(" ") + theme.Selected.Copy().Background(theme.Warm).Render(">") + sp(" ") cursor = bgStr(" ") + bgS(theme.Selected).Render(">") + bgStr(" ")
} }
// ── Selection dot ── // Selection dot
var check string var check string
if f.selected { if f.selected {
if isCursor { check = bgS(theme.StatusDone).Render("●") + bgStr(" ")
check = theme.StatusDone.Copy().Background(theme.Warm).Render("\u25CF") + sp(" ")
} else { } else {
check = theme.StatusDone.Render("\u25CF") + sp(" ") check = bgS(theme.Breadcrumb).Render("") + bgStr(" ")
}
} else {
if isCursor {
check = theme.Breadcrumb.Copy().Background(theme.Warm).Render("\u25CB") + sp(" ")
} else {
check = theme.Breadcrumb.Render("\u25CB") + sp(" ")
}
} }
// ── Icon + ext badge + filename ── // Icon + ext badge + filename
icon := detect.CategoryIcon(f.category) icon := detect.CategoryIcon(f.category)
catColor := theme.CategoryColor(string(f.category)) catColor := theme.CategoryColor(string(f.category))
extBadge := bgS(theme.ExtBadge(catColor)).Render(strings.ToUpper(f.ext))
var extBadge string
if isCursor {
extBadge = theme.ExtBadge(catColor).Background(theme.Warm).Render(strings.ToUpper(f.ext))
} else {
extBadge = theme.ExtBadge(catColor).Render(strings.ToUpper(f.ext))
}
nameText := f.name nameText := f.name
maxName := nw - 10 maxName := nw - 10
@@ -265,104 +220,59 @@ func (m Model) renderFileRow(idx int) string {
nameText = nameText[:maxName-3] + "..." nameText = nameText[:maxName-3] + "..."
} }
var nameStyle lipgloss.Style nameStyle := theme.FileName
if isCursor { if isCursor {
nameStyle = theme.FileName.Copy().Background(theme.Warm).Bold(true) nameStyle = nameStyle.Copy().Bold(true)
} else {
nameStyle = theme.FileName
} }
nameContent := sp(icon+" ") + extBadge + sp(" ") + nameStyle.Render(nameText) nameContent := bgStr(icon+" ") + extBadge + bgStr(" ") + bgS(nameStyle).Render(nameText)
var nameCellStyle lipgloss.Style nameCell := bgS(lipgloss.NewStyle()).Copy().Width(nw).MaxWidth(nw).Render(nameContent)
if isCursor {
nameCellStyle = lipgloss.NewStyle().Width(nw).MaxWidth(nw).Background(theme.Warm)
} else {
nameCellStyle = lipgloss.NewStyle().Width(nw).MaxWidth(nw).Background(theme.ScreenBg)
}
nameCell := nameCellStyle.Render(nameContent)
// ── Size ── // Size
var sizeCell string sizeCell := bgS(theme.FileSize).Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
if isCursor {
sizeCell = theme.FileSize.Copy().Background(theme.Warm).Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
} else {
sizeCell = theme.FileSize.Copy().Width(colSize).Align(lipgloss.Right).Render(formatSize(f.size))
}
// ── Format selector ── // Format selector
fmtStr := renderFormatSelector(f, isCursor) fmtStr := renderFormatSelector(f, isCursor, bgStr, bgS)
var fmtCell string fmtCell := bgS(lipgloss.NewStyle()).Copy().Width(colFormat).Align(lipgloss.Center).Render(fmtStr)
if isCursor {
fmtCell = lipgloss.NewStyle().Width(colFormat).Align(lipgloss.Center).Background(theme.Warm).Render(fmtStr)
} else {
fmtCell = lipgloss.NewStyle().Width(colFormat).Align(lipgloss.Center).Background(theme.ScreenBg).Render(fmtStr)
}
// ── Status ── // Status
var statusStr string var statusStr string
switch f.status { switch f.status {
case "idle": case "idle":
if isCursor { statusStr = bgS(theme.StatusIdle).Render("idle")
statusStr = theme.StatusIdle.Copy().Background(theme.Warm).Render("idle")
} else {
statusStr = theme.StatusIdle.Render("idle")
}
case "converting": case "converting":
if isCursor { statusStr = bgS(theme.StatusConverting).Render("converting...")
statusStr = theme.StatusConverting.Copy().Background(theme.Warm).Render("converting...")
} else {
statusStr = theme.StatusConverting.Render("converting...")
}
case "done": case "done":
if isCursor { statusStr = bgS(theme.StatusDone).Render("done")
statusStr = theme.StatusDone.Copy().Background(theme.Warm).Render("done")
} else {
statusStr = theme.StatusDone.Render("done")
}
case "error": case "error":
if isCursor { statusStr = bgS(theme.StatusError).Render("error")
statusStr = theme.StatusError.Copy().Background(theme.Warm).Render("error")
} else {
statusStr = theme.StatusError.Render("error")
}
}
var statusCell string
if isCursor {
statusCell = lipgloss.NewStyle().Width(colStatus).Align(lipgloss.Center).Background(theme.Warm).Render(statusStr)
} else {
statusCell = lipgloss.NewStyle().Width(colStatus).Align(lipgloss.Center).Background(theme.ScreenBg).Render(statusStr)
} }
statusCell := bgS(lipgloss.NewStyle()).Copy().Width(colStatus).Align(lipgloss.Center).Render(statusStr)
return cursor + check + nameCell + sp(" ") + sizeCell + sp(" ") + fmtCell + sp(" ") + statusCell row := cursor + check + nameCell + bgStr(" ") + sizeCell + bgStr(" ") + fmtCell + bgStr(" ") + statusCell
return lineBg(row)
} }
func renderFormatSelector(f fileEntry, active bool) string { func renderFormatSelector(f fileEntry, active bool, bgStr func(string) string, bgS func(lipgloss.Style) lipgloss.Style) string {
if len(f.formats) == 0 { if len(f.formats) == 0 {
if active { return bgS(theme.Help).Render("—")
return theme.Help.Copy().Background(theme.Warm).Render("\u2014")
}
return theme.Help.Render("\u2014")
} }
sp := bg left := bgStr(" ")
if active { right := bgStr(" ")
sp = wbg
}
left := sp(" ")
right := sp(" ")
if active && f.formatIdx > 0 { if active && f.formatIdx > 0 {
left = theme.Help.Copy().Background(theme.Warm).Render("< ") left = bgS(theme.Help).Render("< ")
} }
if active && f.formatIdx < len(f.formats)-1 { if active && f.formatIdx < len(f.formats)-1 {
right = theme.Help.Copy().Background(theme.Warm).Render(" >") right = bgS(theme.Help).Render(" >")
} }
var middle string var middle string
if active { if active {
middle = theme.Selected.Copy().Background(theme.Warm).Render(f.targetFormat) middle = bgS(theme.Selected).Render(f.targetFormat)
} else { } else {
middle = theme.Unselected.Render(f.targetFormat) middle = bgS(theme.Unselected).Render(f.targetFormat)
} }
return left + middle + right return left + middle + right
@@ -380,19 +290,29 @@ func (m Model) renderBottomBar() string {
var left string var left string
if selected > 0 { if selected > 0 {
left = bg(" ") + theme.ButtonPrimary.Render(fmt.Sprintf(" Convert %d files [c] ", selected)) left = theme.BgStr(" ") + theme.ButtonPrimary.Render(fmt.Sprintf(" Convert %d files [c] ", selected))
} else { } else {
left = bg(" ") + theme.Help.Render("Select files to convert") left = theme.BgStr(" ") + theme.Bg(theme.Help).Render("Select files to convert")
} }
right := theme.Help.Render("up/down navigate left/right format space select a all q quit") + bg(" ") // Adaptive keybindings: full or compact based on available space
leftW := lipgloss.Width(left)
fullHelp := "up/down navigate left/right format space select a all q quit"
shortHelp := "↑↓ nav ←→ fmt space sel a all 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) gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 { if gap < 1 {
gap = 1 gap = 1
} }
return left + theme.BgStr(strings.Repeat(" ", gap)) + right
return left + bg(strings.Repeat(" ", gap)) + right
} }
// ─── Converting view ───────────────────────────────────────── // ─── Converting view ─────────────────────────────────────────
@@ -400,10 +320,9 @@ func (m Model) renderBottomBar() string {
func (m Model) renderConverting() string { func (m Model) renderConverting() string {
elapsed := time.Since(m.startTime).Round(time.Millisecond) elapsed := time.Since(m.startTime).Round(time.Millisecond)
header := theme.StatusConverting.Render(fmt.Sprintf( header := theme.Bg(theme.StatusConverting).Render(fmt.Sprintf(
" Converting... %d/%d (%s)", m.converted, m.totalToConv, elapsed)) " Converting... %d/%d (%s)", m.converted, m.totalToConv, elapsed))
// Progress bar
barWidth := m.width - 8 barWidth := m.width - 8
if barWidth < 20 { if barWidth < 20 {
barWidth = 20 barWidth = 20
@@ -414,27 +333,21 @@ func (m Model) renderConverting() string {
filled = barWidth filled = barWidth
} }
bar := bg(" ") + bar := theme.BgStr(" ") +
theme.ProgressFilled.Render(strings.Repeat("\u2588", filled)) + theme.Bg(theme.ProgressFilled).Render(strings.Repeat("", filled)) +
theme.ProgressEmpty.Render(strings.Repeat("\u2591", barWidth-filled)) theme.Bg(theme.ProgressEmpty).Render(strings.Repeat("", barWidth-filled))
// Show current files being converted
var current []string var current []string
for _, f := range m.files { for _, f := range m.files {
if f.status == "converting" { if f.status == "converting" {
current = append(current, fmt.Sprintf(" %s \u2192 %s", f.name, f.targetFormat)) current = append(current, fmt.Sprintf(" %s %s", f.name, f.targetFormat))
} }
} }
currentStr := theme.Help.Render(strings.Join(current, "\n")) currentStr := theme.Bg(theme.Help).Render(strings.Join(current, "\n"))
return lipgloss.JoinVertical(lipgloss.Left, return lipgloss.JoinVertical(lipgloss.Left,
"", "", header, bar, "", currentStr, "",
header, theme.Bg(theme.Help).Render(" Press q to cancel"),
bar,
"",
currentStr,
"",
theme.Help.Render(" Press q to cancel"),
) )
} }
@@ -457,31 +370,38 @@ func (m Model) renderResults() string {
} }
elapsed := time.Since(m.startTime).Round(time.Millisecond) elapsed := time.Since(m.startTime).Round(time.Millisecond)
summary := theme.StatusDone.Render(fmt.Sprintf( summary := theme.Bg(theme.StatusDone).Render(fmt.Sprintf(
" Conversion complete! %d succeeded", successCount)) " Conversion complete! %d succeeded", successCount))
if errorCount > 0 { if errorCount > 0 {
summary += theme.StatusError.Render(fmt.Sprintf(", %d failed", errorCount)) summary += theme.Bg(theme.StatusError).Render(fmt.Sprintf(", %d failed", errorCount))
} }
summary += theme.Help.Render(fmt.Sprintf(" (%s)", elapsed)) summary += theme.Bg(theme.Help).Render(fmt.Sprintf(" (%s)", elapsed))
rows = append(rows, "", summary, "") rows = append(rows, "", summary, "")
// List results
for _, f := range m.files { for _, f := range m.files {
if !f.selected { if !f.selected {
continue 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 { switch f.status {
case "done": case "done":
rows = append(rows, theme.StatusDone.Render(" \u2713 ")+ rows = append(rows,
theme.FileName.Render(f.name)+ theme.BgStr(" ")+theme.Bg(theme.StatusDone).Render("✓")+" "+
theme.Help.Render(" \u2192 ")+ theme.BgStr(icon+" ")+extBadge+theme.BgStr(" ")+
theme.BreadcrumbActive.Render(f.outputPath)) theme.Bg(theme.FileName).Render(f.name)+
theme.Bg(theme.Help).Render(" → ")+
theme.Bg(theme.BreadcrumbActive).Render(f.outputPath))
case "error": case "error":
rows = append(rows, theme.StatusError.Render(" \u2717 ")+ rows = append(rows,
theme.FileName.Render(f.name)+ theme.BgStr(" ")+theme.Bg(theme.StatusError).Render("✗")+" "+
theme.Help.Render(" \u2014 ")+ theme.BgStr(icon+" ")+extBadge+theme.BgStr(" ")+
theme.StatusError.Render(f.error)) theme.Bg(theme.FileName).Render(f.name)+
theme.Bg(theme.Help).Render(" — ")+
theme.Bg(theme.StatusError).Render(f.error))
} }
} }
@@ -489,7 +409,7 @@ func (m Model) renderResults() string {
} }
func (m Model) renderResultsFooter() string { func (m Model) renderResultsFooter() string {
return theme.Help.Render(" Press enter to exit | esc to convert more") return theme.Bg(theme.Help).Render(" Press enter to exit | esc to convert more")
} }
// ─── Help overlay ──────────────────────────────────────────── // ─── Help overlay ────────────────────────────────────────────
@@ -511,13 +431,13 @@ func (m Model) renderHelp() string {
var lines []string var lines []string
lines = append(lines, "") lines = append(lines, "")
lines = append(lines, theme.BreadcrumbActive.Render(" Keyboard Shortcuts")) lines = append(lines, theme.Bg(theme.BreadcrumbActive).Render(" Keyboard Shortcuts"))
lines = append(lines, "") lines = append(lines, "")
for _, k := range keys { for _, k := range keys {
lines = append(lines, bg(" ")+ lines = append(lines, fmt.Sprintf(" %s %s",
theme.Selected.Copy().Width(18).Render(k.key)+bg(" ")+ theme.Bg(theme.Selected).Copy().Width(18).Render(k.key),
theme.Help.Render(k.desc)) theme.Bg(theme.Help).Render(k.desc)))
} }
lines = append(lines, "") lines = append(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.2" CurrentVersion = "0.1.3"
repoOwner = "noauf" repoOwner = "noauf"
repoName = "Transmute" repoName = "Transmute"