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:
+28
-76
@@ -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
@@ -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 {
|
|
||||||
check = theme.StatusDone.Render("\u25CF") + sp(" ")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if isCursor {
|
check = bgS(theme.Breadcrumb).Render("○") + bgStr(" ")
|
||||||
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, "")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user