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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 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 {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
bar := progress.New(
|
||||
progress.WithSolidFill(ColorForPct(pct)),
|
||||
progress.WithWidth(barW),
|
||||
progress.WithoutPercentage(),
|
||||
)
|
||||
bar.EmptyColor = string(t.TextDim)
|
||||
filled := int(pct * float64(barW))
|
||||
if filled > barW {
|
||||
filled = barW
|
||||
}
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
||||
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
|
||||
bar := barStyle.Render(strings.Repeat("█", filled)) +
|
||||
emptyStyle.Render(strings.Repeat("░", barW-filled))
|
||||
|
||||
return fmt.Sprintf("%s %s %s",
|
||||
labelStyle.Render(label),
|
||||
bar.ViewAs(pct),
|
||||
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
|
||||
)
|
||||
spaceStyle := lipgloss.NewStyle().
|
||||
Background(t.SurfaceHover)
|
||||
|
||||
return labelStyle.Render(label+" ") + bar + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user