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
import (
"strings"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
@@ -25,7 +27,7 @@ func LayoutRow(totalWidth, n int) []int {
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.
func MetricCard(label, value, delta string, outerWidth int) string {
t := theme.Active
@@ -35,23 +37,56 @@ func MetricCard(label, value, delta string, outerWidth int) string {
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().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.Border).
BorderBackground(t.Background).
Background(t.Surface).
Width(contentWidth).
Padding(0, 1)
iconStyle := lipgloss.NewStyle().
Foreground(valueColor).
Background(t.Surface)
labelStyle := lipgloss.NewStyle().
Foreground(t.TextMuted)
Foreground(t.TextMuted).
Background(t.Surface)
valueStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary).
Foreground(valueColor).
Background(t.Surface).
Bold(true)
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)
if 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]))
}
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.
@@ -87,14 +123,59 @@ func ContentCard(title, body string, outerWidth int) string {
contentWidth = 10
}
// Use accent border for titled cards, subtle for untitled
borderColor := t.Border
if title != "" {
borderColor = t.BorderBright
}
cardStyle := lipgloss.NewStyle().
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).
Padding(0, 1)
titleStyle := lipgloss.NewStyle().
Foreground(t.TextMuted).
Foreground(t.AccentBright).
Background(t.Surface).
Bold(true)
content := ""
@@ -106,12 +187,57 @@ func ContentCard(title, body string, outerWidth int) string {
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 {
if len(cards) == 0 {
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
@@ -123,3 +249,10 @@ func CardInnerWidth(outerWidth int) int {
}
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 {
return ""
}
t := theme.Active
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
@@ -28,7 +29,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
peak = 1
}
style := lipgloss.NewStyle().Foreground(color)
style := lipgloss.NewStyle().Foreground(color).Background(t.Surface)
var buf strings.Builder
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())
}
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels.
// 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.
// BarChart renders a visually polished bar chart with gradient-style coloring.
func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
if len(values) == 0 {
return ""
@@ -70,10 +69,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
maxVal = 1
}
// Y-axis: compute tick step and ceiling, then fit within requested height.
// 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.
// Y-axis: compute tick step and ceiling
tickStep := chartTickStep(maxVal)
maxIntervals := height / 2
if maxIntervals < 2 {
@@ -92,14 +88,13 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
numIntervals = 1
}
// Each interval gets the same number of rows; chart height is an exact multiple.
rowsPerTick := height / numIntervals
if rowsPerTick < 2 {
rowsPerTick = 2
}
chartH := rowsPerTick * numIntervals
// Pre-compute tick labels at evenly-spaced row positions
// Pre-compute tick labels
yLabelW := len(formatChartLabel(ceiling)) + 1
if 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))
}
// Chart area width (excluding y-axis label and axis line char)
// Chart area width
chartW := width - yLabelW - 1
if chartW < 5 {
chartW = 5
@@ -118,8 +113,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
n := len(values)
// Bar sizing: always use 1-char gaps, target barW >= 2.
// If bars don't fit at width 2, subsample to fewer bars.
// Bar sizing
gap := 1
if n <= 1 {
gap = 0
@@ -131,8 +125,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
barW = chartW
}
if barW < 2 && n > 1 {
// Subsample so bars fit at width 2 with 1-char gaps
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
maxN := (chartW + 1) / 3
if 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
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
// Render rows top to bottom using chartH (aligned to tick intervals)
// Render rows top to bottom
for row := chartH; row >= 1; row-- {
rowTop := ceiling * float64(row) / 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]
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 {
if i > 0 && gap > 0 {
b.WriteString(strings.Repeat(" ", gap))
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", gap)))
}
switch {
case v >= rowTop:
b.WriteString(barStyle.Render(strings.Repeat("\u2588", barW)))
b.WriteString(barStyle.Render(strings.Repeat("", barW)))
case v > rowBottom:
frac := (v - rowBottom) / (rowTop - rowBottom)
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)))
default:
b.WriteString(strings.Repeat(" ", barW))
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", barW)))
}
}
b.WriteString("\n")
@@ -209,7 +216,6 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
buf[i] = ' '
}
// Place labels at bar start positions, skip overlaps
minSpacing := 8
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)
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 {
lbl := labels[n-1]
pos := (n - 1) * (barW + gap)
end := pos + len(lbl)
if end > axisLen {
// Right-align: shift left so it ends at the axis edge
pos = axisLen - len(lbl)
end = axisLen
}
@@ -251,8 +254,9 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
}
b.WriteString("\n")
b.WriteString(strings.Repeat(" ", yLabelW+1))
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " ")))
labelStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", yLabelW+1)))
b.WriteString(labelStyle.Render(strings.TrimRight(string(buf), " ")))
}
return b.String()

View File

@@ -11,7 +11,7 @@ import (
"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 {
t := theme.Active
filled := int(pct * float64(width))
@@ -22,36 +22,45 @@ func ProgressBar(pct float64, width int) string {
filled = 0
}
filledStyle := lipgloss.NewStyle().Foreground(t.Accent)
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim)
// Color gradient based on progress
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
for i := 0; i < filled; i++ {
b.WriteString(filledStyle.Render("\u2588"))
}
for i := filled; i < width; i++ {
b.WriteString(emptyStyle.Render("\u2591"))
b.WriteString(filledStyle.Render(strings.Repeat("█", filled)))
b.WriteString(emptyStyle.Render(strings.Repeat("░", width-filled)))
return b.String() + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%.0f%%", pct*100))
}
return fmt.Sprintf("%s %.1f%%", b.String(), 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 {
t := theme.Active
switch {
case pct >= 0.8:
case pct >= 0.9:
return string(t.Red)
case pct >= 0.5:
case pct >= 0.7:
return string(t.Orange)
case pct >= 0.5:
return string(t.Yellow)
default:
return string(t.Green)
}
}
// 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 {
t := theme.Active
@@ -69,9 +78,10 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
)
bar.EmptyColor = string(t.TextDim)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
pctStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Bold(true)
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
countdown := ""
@@ -84,16 +94,16 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
}
}
return fmt.Sprintf("%s %s %s %s",
labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)),
bar.ViewAs(pct),
pctStyle.Render(pctStr),
countdownStyle.Render(countdown),
)
return labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)) +
spaceStyle.Render(" ") +
bar.ViewAs(pct) +
spaceStyle.Render(" ") +
pctStyle.Render(pctStr) +
spaceStyle.Render(" ") +
countdownStyle.Render(countdown)
}
// CompactRateBar renders a tiny status-bar-sized rate indicator.
// Example output: "5h ████░░░░ 42%"
func CompactRateBar(label string, pct float64, width int) string {
t := theme.Active
@@ -104,7 +114,6 @@ func CompactRateBar(label string, pct float64, width int) string {
pct = 1
}
// label + space + bar + space + pct(4 chars)
barW := width - lipgloss.Width(label) - 6
if barW < 4 {
barW = 4
@@ -117,14 +126,15 @@ func CompactRateBar(label string, pct float64, width int) string {
)
bar.EmptyColor = string(t.TextDim)
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
return fmt.Sprintf("%s %s %s",
labelStyle.Render(label),
bar.ViewAs(pct),
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
)
return labelStyle.Render(label) +
spaceStyle.Render(" ") +
bar.ViewAs(pct) +
spaceStyle.Render(" ") +
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
}
func formatCountdown(d time.Duration) string {

View File

@@ -7,80 +7,119 @@ import (
"github.com/theirongolddev/cburn/internal/claudeai"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"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 {
t := theme.Active
style := lipgloss.NewStyle().
Foreground(t.TextMuted).
// Main container
barStyle := lipgloss.NewStyle().
Background(t.SurfaceHover).
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
ratePart := renderStatusRateLimits(subData)
hintStyle := lipgloss.NewStyle().
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
if refreshing {
refreshStyle := lipgloss.NewStyle().Foreground(t.Accent)
right = refreshStyle.Render("↻ refreshing ")
spinnerStyle := lipgloss.NewStyle().
Foreground(t.AccentBright).
Background(t.SurfaceHover).
Bold(true)
right = spinnerStyle.Render("↻ refreshing")
} else if dataAge != "" {
autoStr := ""
refreshIcon := ""
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
usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right)
padding := width - usedWidth
// Calculate padding
leftWidth := lipgloss.Width(left)
middleWidth := lipgloss.Width(middle)
rightWidth := lipgloss.Width(right)
totalUsed := leftWidth + middleWidth + rightWidth
padding := width - totalUsed
if padding < 0 {
padding = 0
}
// Split padding: more on the left side of rate indicators
leftPad := padding / 2
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 {
if subData == nil || subData.Usage == nil {
return ""
}
t := theme.Active
sepStyle := lipgloss.NewStyle().Foreground(t.TextDim)
var parts []string
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 {
parts = append(parts, compactStatusBar("Wk", w.Pct))
parts = append(parts, renderRatePill("Wk", w.Pct))
}
if len(parts) == 0 {
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.
// Format: "5h ████░░░░ 42%"
func compactStatusBar(label string, pct float64) string {
// renderRatePill renders a compact, colored rate indicator pill.
func renderRatePill(label string, pct float64) string {
t := theme.Active
if pct < 0 {
@@ -90,20 +129,52 @@ func compactStatusBar(label string, pct float64) string {
pct = 1
}
barW := 8
bar := progress.New(
progress.WithSolidFill(ColorForPct(pct)),
progress.WithWidth(barW),
progress.WithoutPercentage(),
)
bar.EmptyColor = string(t.TextDim)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
return fmt.Sprintf("%s %s %s",
labelStyle.Render(label),
bar.ViewAs(pct),
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
)
// 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
filled := int(pct * float64(barW))
if filled > barW {
filled = barW
}
bar := barStyle.Render(strings.Repeat("█", filled)) +
emptyStyle.Render(strings.Repeat("░", barW-filled))
spaceStyle := lipgloss.NewStyle().
Background(t.SurfaceHover)
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},
}
// 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 {
t := theme.Active
activeStyle := lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true)
// Container with bottom border
barStyle := lipgloss.NewStyle().
Background(t.Surface).
Width(width)
inactiveStyle := lipgloss.NewStyle().
Foreground(t.TextMuted)
// Active tab: bright text with accent underline
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().
Foreground(t.Accent).
Bold(true)
Background(t.Surface)
dimKeyStyle := lipgloss.NewStyle().
Foreground(t.TextDim)
dimStyle := lipgloss.NewStyle().
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 {
var rendered string
var tabContent string
var underline string
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 {
// Render with highlighted shortcut key
// Inactive tab - show key hint
if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) {
before := tab.Name[:tab.KeyPos]
key := string(tab.Name[tab.KeyPos])
after := tab.Name[tab.KeyPos+1:]
rendered = inactiveStyle.Render(before) +
dimKeyStyle.Render("[") + keyStyle.Render(key) + dimKeyStyle.Render("]") +
inactiveStyle.Render(after)
tabContent = lipgloss.NewStyle().Padding(0, 1).Background(t.Surface).Render(
dimStyle.Render(before) + keyStyle.Render(key) + dimStyle.Render(after))
} else {
// Key not in name (e.g., "Settings" with 'x')
rendered = inactiveStyle.Render(tab.Name) +
dimKeyStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimKeyStyle.Render("]")
tabContent = inactiveTabStyle.Render(tab.Name) +
dimStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimStyle.Render("]")
}
}
parts = append(parts, rendered)
// Dim underline
underline = lipgloss.NewStyle().
Foreground(t.Border).
Background(t.Surface).
Render(strings.Repeat("─", lipgloss.Width(tabContent)))
}
bar := " " + strings.Join(parts, " ")
if lipgloss.Width(bar) <= width {
return bar
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(" "))
}
return lipgloss.NewStyle().MaxWidth(width).Render(bar)
}
// Combine tab row and underline row
tabRow := strings.Join(tabParts, "")
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 barStyle.Render(tabRow + "\n" + underlineRow)
}