Files
cburn/internal/tui/components/card.go
teernisse 79ab17488e feat: add TUI reusable components: metric cards, sparklines, tab bar, and status bar
Implement shared UI components used across the dashboard tabs:

- tui/components/card.go: Three components:

  * MetricCard: bordered card with label (muted), value (bold), and
    optional delta (dim) — used for the dashboard's top-level KPIs.

  * MetricCardRow: renders N cards side-by-side using lipgloss
    horizontal join, auto-calculating card width from available space.

  * Sparkline: theme-colored Unicode block sparkline (8-level,
    auto-scaled to series max). Used in Dashboard and Trends tabs.

  * ProgressBar: filled/empty bar (accent + dim) with percentage
    label. Used in the Budget tab for plan-relative spend.

- tui/components/statusbar.go: Bottom status bar with left-aligned
  keybinding hints ([f]ilter [?]help [q]uit), current filter info,
  and right-aligned data age indicator. Padding auto-fills to
  terminal width.

- tui/components/tabbar.go: 10-tab navigation bar split across two
  rows (Dashboard/Costs/Sessions/Models/Projects on row 1,
  Trends/Efficiency/Activity/Budget/Settings on row 2). Each
  inactive tab highlights its keyboard shortcut letter with [bracket]
  notation. Active tab renders in accent color. Settings uses 'x'
  as its shortcut (not present in the name, so appended). TabIdxByKey
  maps key presses to tab indices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:02:59 -05:00

118 lines
2.4 KiB
Go

package components
import (
"fmt"
"cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)
// MetricCard renders a small metric card with label, value, and delta.
func MetricCard(label, value, delta string, width int) string {
t := theme.Active
cardStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.Border).
Width(width).
Padding(0, 1)
labelStyle := lipgloss.NewStyle().
Foreground(t.TextMuted)
valueStyle := lipgloss.NewStyle().
Foreground(t.TextPrimary).
Bold(true)
deltaStyle := lipgloss.NewStyle().
Foreground(t.TextDim)
content := 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.
func MetricCardRow(cards []struct{ Label, Value, Delta string }, totalWidth int) string {
if len(cards) == 0 {
return ""
}
cardWidth := (totalWidth - len(cards) - 1) / len(cards)
if cardWidth < 10 {
cardWidth = 10
}
var rendered []string
for _, c := range cards {
rendered = append(rendered, MetricCard(c.Label, c.Value, c.Delta, cardWidth))
}
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
}
// Sparkline renders a unicode sparkline from values.
func Sparkline(values []float64, color lipgloss.Color) string {
if len(values) == 0 {
return ""
}
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
max := values[0]
for _, v := range values[1:] {
if v > max {
max = v
}
}
if max == 0 {
max = 1
}
style := lipgloss.NewStyle().Foreground(color)
var result string
for _, v := range values {
idx := int(v / max * float64(len(blocks)-1))
if idx >= len(blocks) {
idx = len(blocks) - 1
}
if idx < 0 {
idx = 0
}
result += string(blocks[idx])
}
return style.Render(result)
}
// ProgressBar renders a colored progress bar.
func ProgressBar(pct float64, width int) string {
t := theme.Active
filled := int(pct * float64(width))
if filled > width {
filled = width
}
if filled < 0 {
filled = 0
}
filledStyle := lipgloss.NewStyle().Foreground(t.Accent)
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim)
bar := ""
for i := 0; i < filled; i++ {
bar += filledStyle.Render("█")
}
for i := filled; i < width; i++ {
bar += emptyStyle.Render("░")
}
return fmt.Sprintf("%s %.1f%%", bar, pct*100)
}