Files
cburn/internal/tui/components/card.go
teernisse c15dc8b487 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>
2026-02-28 00:05:39 -05:00

259 lines
6.4 KiB
Go

// Package components provides reusable TUI widgets for the cburn dashboard.
package components
import (
"strings"
"github.com/theirongolddev/cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)
// LayoutRow distributes totalWidth into n widths that sum to exactly totalWidth.
// First items absorb the remainder from integer division.
func LayoutRow(totalWidth, n int) []int {
if n <= 0 {
return nil
}
base := totalWidth / n
remainder := totalWidth % n
widths := make([]int, n)
for i := range widths {
widths[i] = base
if i < remainder {
widths[i]++
}
}
return widths
}
// 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
contentWidth := outerWidth - 2 // subtract border
if 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().
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).
Background(t.Surface)
valueStyle := lipgloss.NewStyle().
Foreground(valueColor).
Background(t.Surface).
Bold(true)
deltaStyle := lipgloss.NewStyle().
Foreground(t.TextDim).
Background(t.Surface)
spaceStyle := lipgloss.NewStyle().
Background(t.Surface)
// Build content with icon
content := iconStyle.Render(icon) + spaceStyle.Render(" ") + labelStyle.Render(label) + "\n" +
valueStyle.Render(value)
if delta != "" {
content += "\n" + deltaStyle.Render(delta)
}
return cardStyle.Render(content)
}
// MetricCardRow renders a row of metric cards side by side.
// totalWidth is the full row width; cards sum to exactly that.
func MetricCardRow(cards []struct{ Label, Value, Delta string }, totalWidth int) string {
if len(cards) == 0 {
return ""
}
widths := LayoutRow(totalWidth, len(cards))
var rendered []string
for i, c := range cards {
rendered = append(rendered, MetricCard(c.Label, c.Value, c.Delta, widths[i]))
}
// Use CardRow instead of JoinHorizontal to ensure proper background fill
return CardRow(rendered)
}
// ContentCard renders a bordered content card with an optional title.
// outerWidth controls the total rendered width including border.
func ContentCard(title, body string, outerWidth int) string {
t := theme.Active
contentWidth := outerWidth - 2 // subtract border chars
if contentWidth < 10 {
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(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.AccentBright).
Background(t.Surface).
Bold(true)
content := ""
if title != "" {
content = titleStyle.Render(title) + "\n"
}
content += body
return cardStyle.Render(content)
}
// 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 ""
}
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
// given its outer width (subtracts border + padding).
func CardInnerWidth(outerWidth int) int {
w := outerWidth - 4 // 2 border + 2 padding
if w < 10 {
w = 10
}
return w
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}