feat(tui): polish components with icons, gradients, and proper backgrounds

Comprehensive visual refresh of all TUI components:

card.go:
- Add semantic icons based on metric type (tokens=◈, sessions=◉,
  cost=◆, cache=◇)
- Color-code metric values using new theme colors (Cyan, Magenta,
  Green, Blue) for visual variety
- Add CardRow() helper that properly height-equalizes cards and
  fills background on shorter cards to prevent "punched out" gaps
- Set explicit background on all style components

chart.go:
- Add background styling to sparkline renderer
- Ensure bar chart respects theme.Active.Surface background

progress.go:
- Add color gradient based on progress (Cyan→Accent→AccentBright)
- Style percentage text with bold and matching color
- Fix background fill on empty bar segments

statusbar.go:
- Complete redesign with SurfaceHover background
- Style keyboard hints: dim brackets, bright keys, muted labels
- Proper spacing and background continuity across sections
- Styled refresh indicator with AccentBright

tabbar.go:
- Add TabVisualWidth() for accurate mouse hit detection
- Modern underline-style active indicator using ━ characters
- AccentBright for active tab, proper dim styling for inactive
- Consistent Surface background across all tab elements

These changes create a cohesive visual language where every element
respects the dark background, icons add visual interest without
clutter, and color coding provides semantic meaning.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-28 00:05:26 -05:00
parent 19b8bab5d8
commit c15dc8b487
5 changed files with 415 additions and 134 deletions

View File

@@ -2,6 +2,8 @@
package components package components
import ( import (
"strings"
"github.com/theirongolddev/cburn/internal/tui/theme" "github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -25,7 +27,7 @@ func LayoutRow(totalWidth, n int) []int {
return widths return widths
} }
// MetricCard renders a small metric card with label, value, and delta. // MetricCard renders a visually striking metric card with icon, colored value, and delta.
// outerWidth is the total rendered width including border. // outerWidth is the total rendered width including border.
func MetricCard(label, value, delta string, outerWidth int) string { func MetricCard(label, value, delta string, outerWidth int) string {
t := theme.Active t := theme.Active
@@ -35,23 +37,56 @@ func MetricCard(label, value, delta string, outerWidth int) string {
contentWidth = 10 contentWidth = 10
} }
// Determine accent color based on label for variety
var valueColor lipgloss.Color
var icon string
switch {
case strings.Contains(strings.ToLower(label), "token"):
valueColor = t.Cyan
icon = "◈"
case strings.Contains(strings.ToLower(label), "session"):
valueColor = t.Magenta
icon = "◉"
case strings.Contains(strings.ToLower(label), "cost"):
valueColor = t.Green
icon = "◆"
case strings.Contains(strings.ToLower(label), "cache"):
valueColor = t.Blue
icon = "◇"
default:
valueColor = t.Accent
icon = "●"
}
cardStyle := lipgloss.NewStyle(). cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(t.Border). BorderForeground(t.Border).
BorderBackground(t.Background).
Background(t.Surface).
Width(contentWidth). Width(contentWidth).
Padding(0, 1) Padding(0, 1)
iconStyle := lipgloss.NewStyle().
Foreground(valueColor).
Background(t.Surface)
labelStyle := lipgloss.NewStyle(). labelStyle := lipgloss.NewStyle().
Foreground(t.TextMuted) Foreground(t.TextMuted).
Background(t.Surface)
valueStyle := lipgloss.NewStyle(). valueStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary). Foreground(valueColor).
Background(t.Surface).
Bold(true) Bold(true)
deltaStyle := lipgloss.NewStyle(). deltaStyle := lipgloss.NewStyle().
Foreground(t.TextDim) Foreground(t.TextDim).
Background(t.Surface)
spaceStyle := lipgloss.NewStyle().
Background(t.Surface)
content := labelStyle.Render(label) + "\n" + // Build content with icon
content := iconStyle.Render(icon) + spaceStyle.Render(" ") + labelStyle.Render(label) + "\n" +
valueStyle.Render(value) valueStyle.Render(value)
if delta != "" { if delta != "" {
content += "\n" + deltaStyle.Render(delta) content += "\n" + deltaStyle.Render(delta)
@@ -74,7 +109,8 @@ func MetricCardRow(cards []struct{ Label, Value, Delta string }, totalWidth int)
rendered = append(rendered, MetricCard(c.Label, c.Value, c.Delta, widths[i])) rendered = append(rendered, MetricCard(c.Label, c.Value, c.Delta, widths[i]))
} }
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) // Use CardRow instead of JoinHorizontal to ensure proper background fill
return CardRow(rendered)
} }
// ContentCard renders a bordered content card with an optional title. // ContentCard renders a bordered content card with an optional title.
@@ -87,14 +123,59 @@ func ContentCard(title, body string, outerWidth int) string {
contentWidth = 10 contentWidth = 10
} }
// Use accent border for titled cards, subtle for untitled
borderColor := t.Border
if title != "" {
borderColor = t.BorderBright
}
cardStyle := lipgloss.NewStyle(). cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(t.Border). BorderForeground(borderColor).
BorderBackground(t.Background).
Background(t.Surface).
Width(contentWidth).
Padding(0, 1)
// Title with accent color and underline effect
titleStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Background(t.Surface).
Bold(true)
content := ""
if title != "" {
// Title with subtle separator
titleLine := titleStyle.Render(title)
separatorStyle := lipgloss.NewStyle().Foreground(t.Border).Background(t.Surface)
separator := separatorStyle.Render(strings.Repeat("─", minInt(len(title)+2, contentWidth-2)))
content = titleLine + "\n" + separator + "\n"
}
content += body
return cardStyle.Render(content)
}
// PanelCard renders a full-width panel with prominent styling - used for main chart areas.
func PanelCard(title, body string, outerWidth int) string {
t := theme.Active
contentWidth := outerWidth - 2
if contentWidth < 10 {
contentWidth = 10
}
cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.BorderAccent).
BorderBackground(t.Background).
Background(t.Surface).
Width(contentWidth). Width(contentWidth).
Padding(0, 1) Padding(0, 1)
titleStyle := lipgloss.NewStyle(). titleStyle := lipgloss.NewStyle().
Foreground(t.TextMuted). Foreground(t.AccentBright).
Background(t.Surface).
Bold(true) Bold(true)
content := "" content := ""
@@ -106,12 +187,57 @@ func ContentCard(title, body string, outerWidth int) string {
return cardStyle.Render(content) return cardStyle.Render(content)
} }
// CardRow joins pre-rendered card strings horizontally. // CardRow joins pre-rendered card strings horizontally with matched heights.
// This manually joins cards line-by-line to ensure shorter cards are padded
// with proper background fill, avoiding black square artifacts.
func CardRow(cards []string) string { func CardRow(cards []string) string {
if len(cards) == 0 { if len(cards) == 0 {
return "" return ""
} }
return lipgloss.JoinHorizontal(lipgloss.Top, cards...)
t := theme.Active
// Split each card into lines and track widths
cardLines := make([][]string, len(cards))
cardWidths := make([]int, len(cards))
maxHeight := 0
for i, card := range cards {
lines := strings.Split(card, "\n")
cardLines[i] = lines
if len(lines) > maxHeight {
maxHeight = len(lines)
}
// Determine card width from the first line (cards have consistent width)
if len(lines) > 0 {
cardWidths[i] = lipgloss.Width(lines[0])
}
}
// Build background-filled padding style
bgStyle := lipgloss.NewStyle().Background(t.Background)
// Pad shorter cards with background-filled lines
for i := range cardLines {
for len(cardLines[i]) < maxHeight {
// Add a line of spaces with the proper background
padding := bgStyle.Render(strings.Repeat(" ", cardWidths[i]))
cardLines[i] = append(cardLines[i], padding)
}
}
// Join cards line by line
var result strings.Builder
for row := 0; row < maxHeight; row++ {
for i := range cardLines {
result.WriteString(cardLines[i][row])
}
if row < maxHeight-1 {
result.WriteString("\n")
}
}
return result.String()
} }
// CardInnerWidth returns the usable text width inside a ContentCard // CardInnerWidth returns the usable text width inside a ContentCard
@@ -123,3 +249,10 @@ func CardInnerWidth(outerWidth int) int {
} }
return w return w
} }
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -15,6 +15,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
if len(values) == 0 { if len(values) == 0 {
return "" return ""
} }
t := theme.Active
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
@@ -28,7 +29,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
peak = 1 peak = 1
} }
style := lipgloss.NewStyle().Foreground(color) style := lipgloss.NewStyle().Foreground(color).Background(t.Surface)
var buf strings.Builder var buf strings.Builder
buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes
@@ -46,9 +47,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
return style.Render(buf.String()) return style.Render(buf.String())
} }
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels. // BarChart renders a visually polished bar chart with gradient-style coloring.
// labels (if non-nil) should correspond 1:1 with values for x-axis display.
// height is a target; actual height adjusts slightly so Y-axis ticks are evenly spaced.
func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string { func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
if len(values) == 0 { if len(values) == 0 {
return "" return ""
@@ -70,10 +69,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
maxVal = 1 maxVal = 1
} }
// Y-axis: compute tick step and ceiling, then fit within requested height. // Y-axis: compute tick step and ceiling
// Each interval needs at least 2 rows for readable spacing, so
// maxIntervals = height/2. If the initial step gives too many intervals,
// double it until they fit.
tickStep := chartTickStep(maxVal) tickStep := chartTickStep(maxVal)
maxIntervals := height / 2 maxIntervals := height / 2
if maxIntervals < 2 { if maxIntervals < 2 {
@@ -92,14 +88,13 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
numIntervals = 1 numIntervals = 1
} }
// Each interval gets the same number of rows; chart height is an exact multiple.
rowsPerTick := height / numIntervals rowsPerTick := height / numIntervals
if rowsPerTick < 2 { if rowsPerTick < 2 {
rowsPerTick = 2 rowsPerTick = 2
} }
chartH := rowsPerTick * numIntervals chartH := rowsPerTick * numIntervals
// Pre-compute tick labels at evenly-spaced row positions // Pre-compute tick labels
yLabelW := len(formatChartLabel(ceiling)) + 1 yLabelW := len(formatChartLabel(ceiling)) + 1
if yLabelW < 4 { if yLabelW < 4 {
yLabelW = 4 yLabelW = 4
@@ -110,7 +105,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
tickLabels[row] = formatChartLabel(tickStep * float64(i)) tickLabels[row] = formatChartLabel(tickStep * float64(i))
} }
// Chart area width (excluding y-axis label and axis line char) // Chart area width
chartW := width - yLabelW - 1 chartW := width - yLabelW - 1
if chartW < 5 { if chartW < 5 {
chartW = 5 chartW = 5
@@ -118,8 +113,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
n := len(values) n := len(values)
// Bar sizing: always use 1-char gaps, target barW >= 2. // Bar sizing
// If bars don't fit at width 2, subsample to fewer bars.
gap := 1 gap := 1
if n <= 1 { if n <= 1 {
gap = 0 gap = 0
@@ -131,8 +125,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
barW = chartW barW = chartW
} }
if barW < 2 && n > 1 { if barW < 2 && n > 1 {
// Subsample so bars fit at width 2 with 1-char gaps maxN := (chartW + 1) / 3
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
if maxN < 2 { if maxN < 2 {
maxN = 2 maxN = 2
} }
@@ -159,15 +152,29 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
axisLen := n*barW + max(0, n-1)*gap axisLen := n*barW + max(0, n-1)*gap
blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
barStyle := lipgloss.NewStyle().Foreground(color)
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim) // Multi-color gradient for bars based on height
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
var b strings.Builder var b strings.Builder
// Render rows top to bottom using chartH (aligned to tick intervals) // Render rows top to bottom
for row := chartH; row >= 1; row-- { for row := chartH; row >= 1; row-- {
rowTop := ceiling * float64(row) / float64(chartH) rowTop := ceiling * float64(row) / float64(chartH)
rowBottom := ceiling * float64(row-1) / float64(chartH) rowBottom := ceiling * float64(row-1) / float64(chartH)
rowPct := float64(row) / float64(chartH) // How high in the chart (0=bottom, 1=top)
// Choose bar color based on row height (gradient effect)
var barColor lipgloss.Color
switch {
case rowPct > 0.8:
barColor = t.AccentBright
case rowPct > 0.5:
barColor = color
default:
barColor = t.Accent
}
barStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface)
label := tickLabels[row] label := tickLabels[row]
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label))) b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label)))
@@ -175,11 +182,11 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
for i, v := range values { for i, v := range values {
if i > 0 && gap > 0 { if i > 0 && gap > 0 {
b.WriteString(strings.Repeat(" ", gap)) b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", gap)))
} }
switch { switch {
case v >= rowTop: case v >= rowTop:
b.WriteString(barStyle.Render(strings.Repeat("\u2588", barW))) b.WriteString(barStyle.Render(strings.Repeat("", barW)))
case v > rowBottom: case v > rowBottom:
frac := (v - rowBottom) / (rowTop - rowBottom) frac := (v - rowBottom) / (rowTop - rowBottom)
idx := int(frac * 8) idx := int(frac * 8)
@@ -191,7 +198,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
} }
b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW))) b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW)))
default: default:
b.WriteString(strings.Repeat(" ", barW)) b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", barW)))
} }
} }
b.WriteString("\n") b.WriteString("\n")
@@ -209,7 +216,6 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
buf[i] = ' ' buf[i] = ' '
} }
// Place labels at bar start positions, skip overlaps
minSpacing := 8 minSpacing := 8
labelStep := max(1, (n*minSpacing)/(axisLen+1)) labelStep := max(1, (n*minSpacing)/(axisLen+1))
@@ -231,14 +237,11 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
copy(buf[pos:end], lbl) copy(buf[pos:end], lbl)
lastEnd = end + 1 lastEnd = end + 1
} }
// Always attempt the last label (the loop may skip it due to labelStep).
// Right-align to axis edge if it would overflow.
if n > 1 { if n > 1 {
lbl := labels[n-1] lbl := labels[n-1]
pos := (n - 1) * (barW + gap) pos := (n - 1) * (barW + gap)
end := pos + len(lbl) end := pos + len(lbl)
if end > axisLen { if end > axisLen {
// Right-align: shift left so it ends at the axis edge
pos = axisLen - len(lbl) pos = axisLen - len(lbl)
end = axisLen end = axisLen
} }
@@ -251,8 +254,9 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(strings.Repeat(" ", yLabelW+1)) labelStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " "))) b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", yLabelW+1)))
b.WriteString(labelStyle.Render(strings.TrimRight(string(buf), " ")))
} }
return b.String() return b.String()

View File

@@ -11,7 +11,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
// ProgressBar renders a colored progress bar. // ProgressBar renders a visually appealing progress bar with percentage.
func ProgressBar(pct float64, width int) string { func ProgressBar(pct float64, width int) string {
t := theme.Active t := theme.Active
filled := int(pct * float64(width)) filled := int(pct * float64(width))
@@ -22,36 +22,45 @@ func ProgressBar(pct float64, width int) string {
filled = 0 filled = 0
} }
filledStyle := lipgloss.NewStyle().Foreground(t.Accent) // Color gradient based on progress
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim) var barColor lipgloss.Color
switch {
case pct >= 0.8:
barColor = t.AccentBright
case pct >= 0.5:
barColor = t.Accent
default:
barColor = t.Cyan
}
filledStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface)
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
pctStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface).Bold(true)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
var b strings.Builder var b strings.Builder
for i := 0; i < filled; i++ { b.WriteString(filledStyle.Render(strings.Repeat("█", filled)))
b.WriteString(filledStyle.Render("\u2588")) b.WriteString(emptyStyle.Render(strings.Repeat("░", width-filled)))
}
for i := filled; i < width; i++ {
b.WriteString(emptyStyle.Render("\u2591"))
}
return fmt.Sprintf("%s %.1f%%", b.String(), pct*100) return b.String() + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%.0f%%", pct*100))
} }
// ColorForPct returns green/yellow/red based on utilization level. // ColorForPct returns green/yellow/orange/red based on utilization level.
func ColorForPct(pct float64) string { func ColorForPct(pct float64) string {
t := theme.Active t := theme.Active
switch { switch {
case pct >= 0.8: case pct >= 0.9:
return string(t.Red) return string(t.Red)
case pct >= 0.5: case pct >= 0.7:
return string(t.Orange) return string(t.Orange)
case pct >= 0.5:
return string(t.Yellow)
default: default:
return string(t.Green) return string(t.Green)
} }
} }
// RateLimitBar renders a labeled progress bar with percentage and countdown. // RateLimitBar renders a labeled progress bar with percentage and countdown.
// label: "5-hour", "Weekly", etc. pct: 0.0-1.0. resetsAt: zero means no countdown.
// barWidth: width allocated for the progress bar portion only.
func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidth int) string { func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidth int) string {
t := theme.Active t := theme.Active
@@ -69,9 +78,10 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
) )
bar.EmptyColor = string(t.TextDim) bar.EmptyColor = string(t.TextDim)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
pctStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Bold(true) pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim) countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
pctStr := fmt.Sprintf("%3.0f%%", pct*100) pctStr := fmt.Sprintf("%3.0f%%", pct*100)
countdown := "" countdown := ""
@@ -84,16 +94,16 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
} }
} }
return fmt.Sprintf("%s %s %s %s", return labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)) +
labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)), spaceStyle.Render(" ") +
bar.ViewAs(pct), bar.ViewAs(pct) +
pctStyle.Render(pctStr), spaceStyle.Render(" ") +
countdownStyle.Render(countdown), pctStyle.Render(pctStr) +
) spaceStyle.Render(" ") +
countdownStyle.Render(countdown)
} }
// CompactRateBar renders a tiny status-bar-sized rate indicator. // CompactRateBar renders a tiny status-bar-sized rate indicator.
// Example output: "5h ████░░░░ 42%"
func CompactRateBar(label string, pct float64, width int) string { func CompactRateBar(label string, pct float64, width int) string {
t := theme.Active t := theme.Active
@@ -104,7 +114,6 @@ func CompactRateBar(label string, pct float64, width int) string {
pct = 1 pct = 1
} }
// label + space + bar + space + pct(4 chars)
barW := width - lipgloss.Width(label) - 6 barW := width - lipgloss.Width(label) - 6
if barW < 4 { if barW < 4 {
barW = 4 barW = 4
@@ -117,14 +126,15 @@ func CompactRateBar(label string, pct float64, width int) string {
) )
bar.EmptyColor = string(t.TextDim) bar.EmptyColor = string(t.TextDim)
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))) pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
return fmt.Sprintf("%s %s %s", return labelStyle.Render(label) +
labelStyle.Render(label), spaceStyle.Render(" ") +
bar.ViewAs(pct), bar.ViewAs(pct) +
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)), spaceStyle.Render(" ") +
) pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
} }
func formatCountdown(d time.Duration) string { func formatCountdown(d time.Duration) string {

View File

@@ -7,80 +7,119 @@ import (
"github.com/theirongolddev/cburn/internal/claudeai" "github.com/theirongolddev/cburn/internal/claudeai"
"github.com/theirongolddev/cburn/internal/tui/theme" "github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
// RenderStatusBar renders the bottom status bar with optional rate limit indicators. // RenderStatusBar renders a polished bottom status bar with rate limits and controls.
func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData, refreshing, autoRefresh bool) string { func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData, refreshing, autoRefresh bool) string {
t := theme.Active t := theme.Active
style := lipgloss.NewStyle(). // Main container
Foreground(t.TextMuted). barStyle := lipgloss.NewStyle().
Background(t.SurfaceHover).
Width(width) Width(width)
left := " [?]help [r]efresh [q]uit" // Build left section: keyboard hints
keyStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.SurfaceHover).
Bold(true)
// Build rate limit indicators for the middle section hintStyle := lipgloss.NewStyle().
ratePart := renderStatusRateLimits(subData) Foreground(t.TextMuted).
Background(t.SurfaceHover)
// Build right side with refresh status bracketStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.SurfaceHover)
spaceStyle := lipgloss.NewStyle().
Background(t.SurfaceHover)
left := spaceStyle.Render(" ") +
bracketStyle.Render("[") + keyStyle.Render("?") + bracketStyle.Render("]") + hintStyle.Render("help") + spaceStyle.Render(" ") +
bracketStyle.Render("[") + keyStyle.Render("r") + bracketStyle.Render("]") + hintStyle.Render("efresh") + spaceStyle.Render(" ") +
bracketStyle.Render("[") + keyStyle.Render("q") + bracketStyle.Render("]") + hintStyle.Render("uit")
// Build middle section: rate limit indicators
middle := renderStatusRateLimits(subData)
// Build right section: refresh status
var right string var right string
if refreshing { if refreshing {
refreshStyle := lipgloss.NewStyle().Foreground(t.Accent) spinnerStyle := lipgloss.NewStyle().
right = refreshStyle.Render("↻ refreshing ") Foreground(t.AccentBright).
Background(t.SurfaceHover).
Bold(true)
right = spinnerStyle.Render("↻ refreshing")
} else if dataAge != "" { } else if dataAge != "" {
autoStr := "" refreshIcon := ""
if autoRefresh { if autoRefresh {
autoStr = "↻ " refreshIcon = lipgloss.NewStyle().
Foreground(t.Green).
Background(t.SurfaceHover).
Render("↻ ")
} }
right = fmt.Sprintf("%sData: %s ", autoStr, dataAge) dataStyle := lipgloss.NewStyle().
Foreground(t.TextMuted).
Background(t.SurfaceHover)
right = refreshIcon + dataStyle.Render("Data: "+dataAge)
} }
right += spaceStyle.Render(" ")
// Layout: left + ratePart + right, with padding distributed // Calculate padding
usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right) leftWidth := lipgloss.Width(left)
padding := width - usedWidth middleWidth := lipgloss.Width(middle)
rightWidth := lipgloss.Width(right)
totalUsed := leftWidth + middleWidth + rightWidth
padding := width - totalUsed
if padding < 0 { if padding < 0 {
padding = 0 padding = 0
} }
// Split padding: more on the left side of rate indicators
leftPad := padding / 2 leftPad := padding / 2
rightPad := padding - leftPad rightPad := padding - leftPad
bar := left + strings.Repeat(" ", leftPad) + ratePart + strings.Repeat(" ", rightPad) + right paddingStyle := lipgloss.NewStyle().Background(t.SurfaceHover)
bar := left +
paddingStyle.Render(strings.Repeat(" ", leftPad)) +
middle +
paddingStyle.Render(strings.Repeat(" ", rightPad)) +
right
return style.Render(bar) return barStyle.Render(bar)
} }
// renderStatusRateLimits renders compact rate limit bars for the status bar. // renderStatusRateLimits renders compact rate limit pills for the status bar.
func renderStatusRateLimits(subData *claudeai.SubscriptionData) string { func renderStatusRateLimits(subData *claudeai.SubscriptionData) string {
if subData == nil || subData.Usage == nil { if subData == nil || subData.Usage == nil {
return "" return ""
} }
t := theme.Active t := theme.Active
sepStyle := lipgloss.NewStyle().Foreground(t.TextDim)
var parts []string var parts []string
if w := subData.Usage.FiveHour; w != nil { if w := subData.Usage.FiveHour; w != nil {
parts = append(parts, compactStatusBar("5h", w.Pct)) parts = append(parts, renderRatePill("5h", w.Pct))
} }
if w := subData.Usage.SevenDay; w != nil { if w := subData.Usage.SevenDay; w != nil {
parts = append(parts, compactStatusBar("Wk", w.Pct)) parts = append(parts, renderRatePill("Wk", w.Pct))
} }
if len(parts) == 0 { if len(parts) == 0 {
return "" return ""
} }
return strings.Join(parts, sepStyle.Render(" | ")) sepStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.SurfaceHover)
return strings.Join(parts, sepStyle.Render(" │ "))
} }
// compactStatusBar renders a tiny inline progress indicator for the status bar. // renderRatePill renders a compact, colored rate indicator pill.
// Format: "5h ████░░░░ 42%" func renderRatePill(label string, pct float64) string {
func compactStatusBar(label string, pct float64) string {
t := theme.Active t := theme.Active
if pct < 0 { if pct < 0 {
@@ -90,20 +129,52 @@ func compactStatusBar(label string, pct float64) string {
pct = 1 pct = 1
} }
// Choose color based on usage level
var barColor, pctColor lipgloss.Color
switch {
case pct >= 0.9:
barColor = t.Red
pctColor = t.Red
case pct >= 0.7:
barColor = t.Orange
pctColor = t.Orange
case pct >= 0.5:
barColor = t.Yellow
pctColor = t.Yellow
default:
barColor = t.Green
pctColor = t.Green
}
labelStyle := lipgloss.NewStyle().
Foreground(t.TextMuted).
Background(t.SurfaceHover)
barStyle := lipgloss.NewStyle().
Foreground(barColor).
Background(t.SurfaceHover)
emptyStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.SurfaceHover)
pctStyle := lipgloss.NewStyle().
Foreground(pctColor).
Background(t.SurfaceHover).
Bold(true)
// Render mini bar (8 chars)
barW := 8 barW := 8
bar := progress.New( filled := int(pct * float64(barW))
progress.WithSolidFill(ColorForPct(pct)), if filled > barW {
progress.WithWidth(barW), filled = barW
progress.WithoutPercentage(), }
)
bar.EmptyColor = string(t.TextDim)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) bar := barStyle.Render(strings.Repeat("█", filled)) +
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))) emptyStyle.Render(strings.Repeat("░", barW-filled))
return fmt.Sprintf("%s %s %s", spaceStyle := lipgloss.NewStyle().
labelStyle.Render(label), Background(t.SurfaceHover)
bar.ViewAs(pct),
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)), return labelStyle.Render(label+" ") + bar + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
)
} }

View File

@@ -24,50 +24,113 @@ var Tabs = []Tab{
{Name: "Settings", Key: 'x', KeyPos: -1}, {Name: "Settings", Key: 'x', KeyPos: -1},
} }
// RenderTabBar renders the tab bar with the given active index. // TabVisualWidth returns the rendered visual width of a tab.
// This must match RenderTabBar's rendering logic exactly for mouse hit detection.
func TabVisualWidth(tab Tab, isActive bool) int {
// Active tabs: just the name with padding (1 on each side)
if isActive {
return len(tab.Name) + 2
}
// Inactive tabs: name with padding, plus "[k]" suffix if shortcut not in name
w := len(tab.Name) + 2
if tab.KeyPos < 0 {
w += 3 // "[k]"
}
return w
}
// RenderTabBar renders a modern tab bar with underline-style active indicator.
func RenderTabBar(activeIdx int, width int) string { func RenderTabBar(activeIdx int, width int) string {
t := theme.Active t := theme.Active
activeStyle := lipgloss.NewStyle(). // Container with bottom border
Foreground(t.Accent). barStyle := lipgloss.NewStyle().
Bold(true) Background(t.Surface).
Width(width)
inactiveStyle := lipgloss.NewStyle(). // Active tab: bright text with accent underline
Foreground(t.TextMuted) activeTabStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.Surface).
Bold(true).
Padding(0, 1)
// Inactive tab: muted text
inactiveTabStyle := lipgloss.NewStyle().
Foreground(t.TextMuted).
Background(t.Surface).
Padding(0, 1)
// Key highlight style
keyStyle := lipgloss.NewStyle(). keyStyle := lipgloss.NewStyle().
Foreground(t.Accent). Foreground(t.Accent).
Bold(true) Background(t.Surface)
dimKeyStyle := lipgloss.NewStyle(). dimStyle := lipgloss.NewStyle().
Foreground(t.TextDim) Foreground(t.TextDim).
Background(t.Surface)
// Separator between tabs
sepStyle := lipgloss.NewStyle().
Foreground(t.Border).
Background(t.Surface)
var tabParts []string
var underlineParts []string
parts := make([]string, 0, len(Tabs))
for i, tab := range Tabs { for i, tab := range Tabs {
var rendered string var tabContent string
var underline string
if i == activeIdx { if i == activeIdx {
rendered = activeStyle.Render(tab.Name) // Active tab - full name, bright
tabContent = activeTabStyle.Render(tab.Name)
// Accent underline
underline = lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.Surface).
Render(strings.Repeat("━", lipgloss.Width(tabContent)))
} else { } else {
// Render with highlighted shortcut key // Inactive tab - show key hint
if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) { if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) {
before := tab.Name[:tab.KeyPos] before := tab.Name[:tab.KeyPos]
key := string(tab.Name[tab.KeyPos]) key := string(tab.Name[tab.KeyPos])
after := tab.Name[tab.KeyPos+1:] after := tab.Name[tab.KeyPos+1:]
rendered = inactiveStyle.Render(before) + tabContent = lipgloss.NewStyle().Padding(0, 1).Background(t.Surface).Render(
dimKeyStyle.Render("[") + keyStyle.Render(key) + dimKeyStyle.Render("]") + dimStyle.Render(before) + keyStyle.Render(key) + dimStyle.Render(after))
inactiveStyle.Render(after)
} else { } else {
// Key not in name (e.g., "Settings" with 'x') tabContent = inactiveTabStyle.Render(tab.Name) +
rendered = inactiveStyle.Render(tab.Name) + dimStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimStyle.Render("]")
dimKeyStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimKeyStyle.Render("]")
} }
// Dim underline
underline = lipgloss.NewStyle().
Foreground(t.Border).
Background(t.Surface).
Render(strings.Repeat("─", lipgloss.Width(tabContent)))
}
tabParts = append(tabParts, tabContent)
underlineParts = append(underlineParts, underline)
// Add separator between tabs (not after last)
if i < len(Tabs)-1 {
tabParts = append(tabParts, sepStyle.Render(" "))
underlineParts = append(underlineParts, sepStyle.Render(" "))
} }
parts = append(parts, rendered)
} }
bar := " " + strings.Join(parts, " ") // Combine tab row and underline row
if lipgloss.Width(bar) <= width { tabRow := strings.Join(tabParts, "")
return bar underlineRow := strings.Join(underlineParts, "")
// Fill remaining width with border
tabRowWidth := lipgloss.Width(tabRow)
if tabRowWidth < width {
padding := width - tabRowWidth
tabRow += lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padding))
underlineRow += lipgloss.NewStyle().Foreground(t.Border).Background(t.Surface).Render(strings.Repeat("─", padding))
} }
return lipgloss.NewStyle().MaxWidth(width).Render(bar)
return barStyle.Render(tabRow + "\n" + underlineRow)
} }